* 기본적인 개념
<button onclick="openWindow()">
open
</button>
일반적인 HTML 문서에서 이벤트핸들러 함수를 등록할때는 위와 같이 작성한다. (인라인 이벤트등록 방식)
<button onClick={openWindow}>
open
</button>
리액트에서는 이벤트 이름에 카멜케이스를 사용한다. 그리고 JSX 문법을 이용하여 문자열이 아닌 함수로 등록한다.
* 이벤트핸들러 함수의 this
import './App.css';
import React, { Component } from 'react';
class App extends Component {
showAlert() {
console.log(this)
alert('this is alert message !')
}
render(){
return (
<div className="App">
<h1>Show alert !</h1>
<button type="button" onClick={this.showAlert}>show</button>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자! 버튼을 클릭했을때 alert 창을 띄운다. this 값을 출력해보면 undefined 로 나온다. 이는 리액트에서는 기본적으로 this 값을 가지고 있지 않기 때문이다.
import './App.css';
import React, { Component } from 'react';
class App extends Component {
constructor(props){
super(props)
this.showAlert = this.showAlert.bind(this);
}
showAlert() {
console.log(this)
alert('this is alert message !')
}
render(){
return (
<div className="App">
<h1>Show alert !</h1>
<button type="button" onClick={this.showAlert}>show</button>
</div>
);
}
}
export default App;
그래서 위와 같이 생성자 함수(constructor) 안에서 이벤트핸들러 함수 showAlert 에 this 값을 바인딩시켜주면 된다. 개발자 도구를 열어서 다시 this 값을 확인해보자.
import './App.css';
import React, { Component } from 'react';
class App extends Component {
showAlert = () => {
console.log(this)
alert('this is alert message !')
}
render(){
return (
<div className="App">
<h1>Show alert !</h1>
<button type="button" onClick={this.showAlert}>show</button>
</div>
);
}
}
export default App;
또는 이벤트핸들러 함수 showAlert 를 화살표 함수로 선언해주면 된다. 화살표 함수는 자체적인 this 값을 가지고 있지 않으므로 외부의 this 인 컴포넌트를 가리키게 된다.
* 버튼 이벤트 처리하기
import './App.css';
import React, { Component } from 'react';
class App extends Component {
state = {
toggle: true
}
toggleScreenMode = () => {
this.setState({toggle: !this.state.toggle})
}
render(){
const { toggle } = this.state
return (
<div className={`normal ${toggle? "": "dark"}`}>
<h1>Change screen mode</h1>
<button type="button" onClick={this.toggleScreenMode}>{ toggle? "DARK": "NORMAL"}</button>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.normal{
position: absolute;
top: 0;
width: 100%;
height: 100vh;
}
.dark{
background: black;
color: white;
}
App.css 파일을 위와 같이 작성하자!
state = {
toggle: true
}
토글 상태를 기억하는 toggle state 를 초기화한다.
toggleScreenMode = () => {
this.setState({toggle: !this.state.toggle})
}
버튼이 클릭될때 실행되는 이벤트핸들러 함수를 정의한다. toggleScreenMode 는 toggle 의 상태를 반대로 변경한다. true 이면 false 로 false 이면 true 로 변경한다.
render(){
const { toggle } = this.state
return (
<div className={`normal ${toggle? "": "dark"}`}>
<h1>Change screen mode</h1>
<button type="button" onClick={this.toggleScreenMode}>{ toggle? "DARK": "NORMAL"}</button>
</div>
);
}
toggle 상태에 따라 정의된 서로 다른 css 스타일을 적용한다. 템플릿 리터럴과 삼항연산자를 사용하여 class 이름을 추가하거나 제거할 수 있도록 하였다. toggle 상태가 false 이면 dark 라는 클래스 이름이 추가되어 해당 스타일이 적용된다. toggle 상태가 true 이면 dark 라는 클래스 이름이 제거되어 normal 스타일만 적용된다.
toggle 상태에 따라 버튼 이름도 변경되도록 하였다.
영단어 리스트에서 특정 단어를 삭제하는 경우를 생각해보자!
const dummyData = [
{
word: 'apple',
meaning: '사과'
},
{
word: 'before',
meaning: '이전의'
},
{
word: 'clean',
meaning: '깨끗한'
},
{
word: 'dummy',
meaning: '가짜의'
},
{
word: 'emergent',
meaning: '긴급한'
},
{
word: 'famouse',
meaning: '유명한'
},
{
word: 'give',
meaning: '(~을) 주다'
},
{
word: 'humble',
meaning: '검소한'
},
{
word: 'ingrave',
meaning: '조각하다'
},
{
word: 'jungle',
meaning: '밀림숲'
},
{
word: 'korea',
meaning: '대한민국'
},
]
export default dummyData;
이전에 활용했던 영단어 데이터를 사용한다.
import './App.css';
import React, { Component } from 'react';
import words from './dummyData'
import Button from './Button'
class App extends Component {
handleRemove = (id, e) => {
const word = e.target.previousSibling.innerText
console.log(word)
console.log(id)
alert(`You want to delete word - ${word}?`)
}
render(){
const wordStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
return (
<div>
<h1 style={{textAlign:'center'}}>Word List</h1>
{words.map( (w, id) => {
return (
<div key={id} style={wordStyle}>
<h2>{w.word}</h2>
<Button size="small" type="button" handleClick={(e) => this.handleRemove(id, e)}>DELETE</Button>
</div>
)
})}
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 수정하자!
import words from './dummyData'
import Button from './Button'
이전에 만들어둔 dummyData 와 Button 컴포넌트를 임포트하여 사용한다.
handleRemove = (id, e) => {
const word = e.target.previousSibling.innerText
console.log(word)
console.log(id)
alert(`You want to delete word - ${word}?`)
}
DELETE 버튼을 클릭했을때 호출되는 이벤트핸들러 함수이다. 제거해야 되는 단어가 무엇인지 알아야 하므로 해당 단어의 id 값을 입력으로 받는다. 또한, 제거할 단어가 무엇인지 조회하기 위하여 e.target 을 사용하였다.
return (
<div>
<h1 style={{textAlign:'center'}}>Word List</h1>
{words.map( (w, id) => {
return (
<div key={id} style={wordStyle}>
<h2>{w.word}</h2>
<Button size="small" type="button" handleClick={(e) => this.handleRemove(id, e)}>DELETE</Button>
</div>
)
})}
</div>
);
words 컬렉션을 순회하면서 화면에 단어 리스트를 보여준다. map 함수의 두번째 인자(id)는 배열의 인덱스를 의미한다. DELETE 버튼을 클릭하면 해당 단어의 id 값을 handleRemove 함수에 전달한다. 이렇게 추가적인 입력값을 이벤트핸들러 함수에 전달할때는 (e) => handler(parameter, e) 와 같이 콜백함수 형태로 작성하면 된다.
아래는 특정 단어 옆의 DELETE 버튼을 클릭했을때의 모습이다.
그럼 이제 단어를 제거해보자!
import './App.css';
import React, { Component } from 'react';
import words from './dummyData'
import Button from './Button'
class App extends Component {
state = {
words: words
}
handleRemove = (id, e) => {
const word = e.target.previousSibling.innerText
console.log(word)
console.log(id)
alert(`You want to delete word - ${word}?`)
const words = this.state.words.filter( (w, index) => index !== id ) // 제거하려는 단어의 id 와 일치하는 요소만 걸러냄
this.setState({words})
}
render(){
const { words } = this.state
const wordStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
return (
<div>
<h1 style={{textAlign:'center'}}>Word List</h1>
{words.map( (w, id) => {
return (
<div key={id} style={wordStyle}>
<h2>{w.word}</h2>
<Button size="small" type="button" handleClick={(e) => this.handleRemove(id, e)}>DELETE</Button>
</div>
)
})}
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 수정하자!
state = {
words: words
}
words 라는 state 가 추가되었다. words state 는 dummyData 로부터 컬렉션 데이터를 가져와서 words state 에 저장한다. 이는 추후에 words state 를 변경하여 리스트를 업데이트하기 위함이다.
handleRemove = (id, e) => {
const word = e.target.previousSibling.innerText
console.log(word)
console.log(id)
alert(`You want to delete word - ${word}?`)
// 추가된 코드
const words = this.state.words.filter( (w, index) => index !== id ) // 제거하려는 단어의 id 와 일치하는 요소만 걸러냄
this.setState({words})
}
배열의 filter 메서드를 사용하여 제거하려는 단어의 id 와 일치하지 않는 단어들만 추려서 다시 words 변수에 저장한다. 이렇게 하면 제거하려는 단어는 words 컬렉션에서 걸러진다. setState 메서드를 사용하여 words 상태를 업데이트하면, render 함수가 다시 호출되면서 업데이트된(특정 단어가 제거된) 단어 리스트를 화면에 보여준다.
render(){
const { words } = this.state
// 중략
}
render 메서드에서는 이제 다른 파일에 정의된 words 컬렉션을 조회하는 것이 아니라 words 상태(state)를 조회해야 한다. 이렇게 하지 않으면 삭제되지 않는 원본 words 를 조회하게 된다.
아직 부족한게 있다. 현재 App 컴포넌트 안에서 모든걸 처리하고 있다. 그렇다면 Word 컴포넌트를 만들고 Word 컴포넌트를 App 컴포넌트에서 불러와서 사용하고 싶다면 어떻게 하면 될까? 리팩토링을 해보자!
import Button from './Button'
function Word({ handleRemove, w }){
const wordStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
const onRemove = (e) => {
handleRemove(e)
}
return (
<div style={wordStyle}>
<h2>{w.word}</h2>
<Button size="small" type="button" handleClick={(e) => onRemove(e)}>DELETE</Button>
</div>
)
}
export default Word
Word.js 파일을 생성하고 위와 같이 작성하자! 이전에 만든 Button 컴포넌트를 임포트하고 사용한다. DELETE 버튼을 클릭하면 Button 컴포넌트 내부의 onClick 이벤트가 실행되고, 이는 handleClick 을 실행하게 된다. handleClick 에는 event 객체(e) 가 인자로 전달된다. handleClick 의 실행은 onRemove(e) 함수를 호출하고 실행시킨다. onRemove(e)의 실행은 연쇄적으로 handleRemove(e) 가 실행되게 한다.
import Button from './Button'
function Word({ handleRemove, w }){
const wordStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
// const onRemove = (e) => {
// handleRemove(e)
// }
return (
<div style={wordStyle}>
<h2>{w.word}</h2>
<Button size="small" type="button" handleClick={handleRemove}>삭제</Button>
</div>
)
}
export default Word
Word 컴포넌트를 위와 같이 작성해도 된다. onRemove 메서드는 굳이 사용하지 않아도 된다.
import React from 'react'
import './Button.css'
function Button({ children, size, color, width, handleClick, disabled }){
return <button className={`Button ${size} ${color} ${width} ${disabled ? 'blocked' : ''}`} onClick={handleClick} disabled={disabled}>{children}</button>
}
export default Button;
Button.defaultProps = {
size: 'medium',
color: 'tomato',
disabled: false
}
Button 컴포넌트는 위와 같다. 이전 수업에서 만들어둔 컴포넌트를 재활용한다. 그리고 App 컴포넌트를 아래와 같이 수정하자!
import './App.css';
import React, { Component } from 'react';
import words from './dummyData'
import Word from './Word'
class App extends Component {
state = {
words: words
}
handleRemove = (id, e) => {
const word = e.target.previousSibling.innerText
console.log(word)
console.log(id)
alert(`You want to delete word - ${word}?`)
const words = this.state.words.filter( (w, index) => index !== id ) // 제거하려는 단어의 id 와 일치하는 요소만 걸러냄
this.setState({words})
}
render(){
const { words } = this.state
return (
<div>
<h1 style={{textAlign:'center'}}>Word List</h1>
{words.map( (w, id) => {
return (
<Word key={id} w={w} handleRemove={(e) => this.handleRemove(id, e)}></Word>
)
})}
</div>
);
}
}
export default App;
handleRemove 는 결국 Word 컴포넌트의 props 로 전달되므로 handleRemove(e) 의 실행은 결과적으로 this.handleRemove(id, e) 가 실행되도록 한다. 즉, 부모로부터 Word 컴포넌트로 handleRemove 메서드를 넘겨준 이유는 App 컴포넌트의 handleRemove 메서드에서 words 상태를 변경해야 하기 때문이다. 다시말해, 자식 컴포넌트에서 부모 컴포넌트에 정의된 상태(state)를 변경하기 위하여 부모 컴포넌트의 메서드를 자식 컴포넌트의 props 로 전달해준 것이다.
이제 다시 이전과 동일하게 동작하는지 테스트해보자!
버튼 이벤트를 활용하여 간단한 이미지 뷰어를 만들어보자!
const imageData = [
{
title: '고양이',
src: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAoHCBUVEhgVFRISGBIYEhgYGBgYEhERERISGBQZGRgVGBgcIS4lHB4rIRgYJzgmKzAxNTU1GiQ7QDs0Py40NTQBDAwMEA8QHhISHjQhISQ0NDQ0MTQ0NDQ0NDQxNDQ0NDQ0NDQ0NDQ0NDQ0NDQxNDQ0NDE0NDQ0MTQ0NDQ0MTQ0NP/AABEIAOEA4QMBIgACEQEDEQH/xAAbAAACAwEBAQAAAAAAAAAAAAAEBQACAwYBB//EADwQAAEDAwIEAwYEBQIHAQAAAAEAAhEDBCESMQVBUWEicYEykaGx0fAGE0LBUmJykuGi8RQVIzOCwvIH/8QAGQEAAwEBAQAAAAAAAAAAAAAAAAEDAgQF/8QAIxEBAQEBAAICAwACAwAAAAAAAAECEQMhEjETIkFhcTJCUf/aAAwDAQACEQMRAD8ABAWjQqgLRqomu0Kztl40KztkgXXSUV903ukprbpVqAqqb/hqrAc3cgyOmcSlNVEcCqaa4B2cCFPc/VTF/aO/tWyMGfksr62DmkEbomz8TYytqlEbR9Fi/tlT60+Zcb4OWOLmjwH4HoldJfSr+1DgRH0XGcU4YWEuaPBv/T2KWN/9dDWP7GVluuis9lzdpuujsjhWRb3Q8KRXKeXHspJcpU4X1FgVtUWBQaOXjRkea9KjNz/SfkUBmF6FFZrSTAGSgnjRK6PhvCRTaKlUDXMsYdm9HvHyC24VwoUmipUE1N2MOzOjnd+yYNGs6jqJz7Oppn+ohR1v5XmVs457rCpTc/xE+riJPkBlL7kwZEfFOHOaz9D557OPlOB7kmvazXOOkQD2ggrUzyHddpVfckGir45CFWs/SOvtFF6otE7Vq0aqhXaqpLtVnbKNXr9kgW3SUVt03ukorbpVqBKiGDy1wcNwZRVRB1Flp9Q/D9yH0w/qPimb2t8z3XJfgmoHUi3m13wXXNcIyI8ypT16/wDFr75QFywFKq9sDIIkHccinVdzRznySm5rD0Ud8Vy5u44QQXPYMDOg9OcFXsrmW4BmUwu7ojbcj3hD20NE6ROo+8xgJZ8mpD1jNojSXN2jzQlfhxcQNQBI5lXc9xqA5DRBIG/kr3LXfma4MRz2bM/RP8uiniyWu4OTPjbI5d+iwdwR/ItPqjBUcKYcPE5ziD2JmCtKFZ7S7V7RjB5+Sf5ND8eSx3B6kRAxncc/9kK+0e3VLTJge8z/AOq6K0vNWoOEFpMHmo8kOEn2s8ttgPn7055df2M3xZ/hXZfh+o8aiNI7p1Z8PZbicPq9cQyRy790RVvxjHhjA6D+IpbSrh7yS8NYOs/sl8ta/wBHM5yYMt3OIc4hs8y4tPotA8NGlj2k9TpdPaCcLWhGnwVKb+zw7Se0gSPVD31s5wM0Wtd/Ex+oR5D9wq4xxPWukvEbipMPPlsB6AIdrsSd1a4a4YcPXGQs3nHYBa0UAXT5d5LJRxkqJycidvaiiiiYduFdqqFdqqk0arP2XjV6/ZIFl0lFbdOLtJ65ylWoFqIOoiqpQjystOy//PLedb8wI8l1dxVBlcz+A7poovbI1B894XQVWyZaQey5939vTo8c/X2x/LLhmQEDcUeUT801qVMCOmQgbiqBuufeva2YW1bSQeo28ua9cxoZMYnP36Jgxw0kjzCU1H/9TRO7gR75+UpTpiLaq0NL3iNUafh/hWu7Xxs5h2HAbOMH79V5xiiQ1hA/UzHqMfJPKlqZYIkgkk9NLTB+KrjN0xrXxczw61LpYRO5LhyEQ0eqrxOm12RlwIIA3AMH5ymdzFGm54wdXL+UQPmlNg0uaXk5LTHYAkg/FHOUd6HtqWoSMkloPKXH/Me9E1Keo6hkNOkCD4oEAdsAEnuvLC4aXkdz2A/SAP8AUjqdHwuJMASCTgbzz5np6I+gXXJBYARg+07+KBsOyXVbYiC0wN9ifQdU2uXtJAjZphu/lPX/AD2QzmPdLzIGwMbxjHZPPpnTGjdVWeEycZEjA7qz+MFvMAebyf8ACz/PjwkYO+BJSq+dNTBwOXRXzUqOfcOqn9MdYysr6oGs0jcrWyBjAxHvQ3EbXQZIjVlHe0c5AC9UUW03qiiiA7gK7VQLRqqk0avXbLxqs7ZIFd2ktwcp1eJHc7pVqAqzkK4resqUGAuAKzWoZ/huo9tUATBwQu4tgQchYcIsGMptdp8RG8ZRuqBJj91weTXdenb488zxqxhOYWFzQGxG/vXj+ItY3EE/Mrm7zjlYv0tZ3xOBO5nASznp3XDWmwskTj5IPh7NdySdmz6YQrOMPbH5lNwYf1bg/D5J/YW7Xn8xhnU3cbEfVUueemfmaWVqH1GkgEN0mP5gZHxATl1ATgcslZ8DtyNRI6QmZZzXTicy5t3unGcesC6mWkeHWfUaQR+4XPuZppgdG6T3JB+oXcfiN8MJHIH5ZPuXzivcl0tGw5Dc7gD5H0WN59t512LcJzUjBl89o+m6ccUrtgMZkjmNgfrnffKS8JBa8uJIkbmDDf2Ta4vqQidWkcxtPyPxU7lT5Mrag1ucE8zGp3/iD81SvWaZzHmRPnC9PEabjgtnq59M+hbCpct/U6HN5aZ0jvBkfFKQWl1zmY9SenQD9kI2g3SS4gDoPaK3rXA/TB+/RCs3ycdgBHkqS8YvsxsLhjRtgdUr4nefmPn9IwFpcWwgw45CBdT07rWeW9Z19cVUXqiomiiiiA7kK7VQLRqqk0avXbLxq9fskCu8SO43Ty8SSvulWoArBa8Oph1Rojdw591nVRHC3gVGnnqCnr6bz9vpDhgN5AD5JfXpipJJljcAd0e+dBM5ICDewtpxmZkxtBXBY72VKgxoJaM+pP8AhLOHvmpWaTk6cdWgfsZVriu9h2Pn1Qr3se7UHGnV76ix3meSpnX68T1nq34mun1f+HZDQxj/ANIjETJ9Amn4VJZUDJ/6byQB0fE49AUA23e8iaU92ODmk9clH21J7a1OSA8PAZTGSS6QSSOcJ9tsYmft9GsqcA/fJWqmFek3SwA7gZ80JcVfFHbK7ZPTmrn/AMQ1cEfcfcr5w2ppLnmJMnt97r6J+IxLCRvHvXzrjNAtp84e46fLeP2WdRrJe6+dUeGBxYyQC4Al0dcLG8pNFXS3WWknSX+2W8pVbZjmOBc0x13Epw2kKjmke1tgEkA7rGtfFqS0rfakMJHL7hMWcOqhrdOuCJiMJ/bcJAaPzSGUw4F2r2nkZDQOQ555rXiXESRFOnoZtqJa1xH7BS+XYpMudq09A8Y9MT5lCVqw3A/dWu7rcAz37+qWF57+q1nNpWyGAupEFDl5Q4Meq0YtycrOr6XUUUW00UUUQHchaNWYWjVVFq1R+yjVH7JGVXiSXO6d3iSXAylWoBqFEcPY0vHiIyFhVYSiLClDwSQM9lPX03n7d65+sNa0EgRKMr2pIESPohrENMHVyGyZvqhuJGy45O/bst5fTn7ii0mJh3Pz7hVbw0OgGJO2xJ9EdXDHvyw6v4gCWnzK6PgthgOI0x/K2StePHyrO9/GFnA+AOa4TIHOBA/x6LoKHB6TKmvTL+RP6cRhM2DCqYGV15xnLm1vVZ1Sl927BjotLm4Sq7vABnp8VrpTFoK6cHSCf9kkuOEMqs0vnwuMQdOevcIPiHEXNc/S4QBPXGyDsuLPa6SZByZknoldZ+q18bPowsuG6DodmdgQ0augE8/VE3TXUxAGlsZgNBj+pNuH3THs1SNtj8jPNC8XLQ0nSyfMgnywsa8Us7Dz5bLyububwAnd8Z5ujuYSK7rvcOY8zEjsEXdjxGA3yB1FL3gnDntA6CNR7FSzniut9ByJ5lSo3HMehROlo2lZPz1+SokFmVtSVTSK1YCAts1ZRRRBIooogO5C0aswtGqqLVq8qL1q8qbJGV3aUVt03u0pe3P1SrUBVZWLHGUXUDRvJ8vCPesm14OG02+bdZ+KzxqO+4MyaLHN3hEvsXvMxjzQP4MvdTSwvk8sAD4LtaFEge0SFH8MtX/LyOetbN7XdueCuxtGeBoiMIM0Qf8AJTCjhuIAVM4+KWtfJoeiGrMMGDnkrPd/Ms31BG6ow5DjHEHsLmuYQB+qZBH7FcpxXjJYNXicXbAHlG5XZ/iemKlMhrtjnvjZcVb8MD263u8DZHKSQYhc25fl7+noePebj/JDWrPquhoeGGNU47rZz9JGNk5vgGhoYG+z1iPv90iqag4RH0Sl6jr7MuFX5pu1Bzyxx2IkDPLK6K8v2fliC1ziP6o9CRC5zh7HviWNOegGkffJPLskM0Opjb2neGekEbeZhWxbxHcjm7pgcTM/26fk6EHUtWxiPvzCdPpVRkCoG9Q4vb/c3CCq63H2ifOH/NOyFKT1BGJ+GFm155pq6h/EG+6D8EJUoNnBI88j4LPGuhWuJKI1nnlaMt9OTusnpxnTwleKKJk9UXiiA7lq0aswtGqqLVq8qbL1pUqO+4QZVdJW6k4nDXH0KcXNV38UD3JVVuncnO85M/4Wa1GZ4a85I0j+bB9yqbBjfaez/wAqmP7WSfiEPVknmT70y4VwovcC7AO2JJ8h++yzbI1JaefhamxtQFpk/wAlNtNv9xJcfevobQYBSrgXCWsaIbHc5d710JpYTgvoMBKtrx2Cq5pCqXJkwdXz7OB6LG4uWgbBe3LOY3XIceo1Hg6XHbyS+XGs57XnHOPsY0+LVuIEHuua4Px9kOpvBaHPJa7BA1dfvmgr20c1kOB1A5kZMiZKTBmYU+/Jf4/HPp2d1Ra9sscCDmW4PqB5FK/+CJcf+5B88dwltJj2eJriIzvCKHHamsw0BuwBycbSVmZha7HS2lkxrcVHagOe8jzXr3PdhlUPO2kgNqHsOR8sLnX8Xe4iAADuBv6Hke6NZTDhrG8S8AAS3b81o+Dm9exxSc/iV7/Vg6owlwcd8kSId/C4bg9itf8AmBd7bGu74n4ggekIhly4Ea/FAgPEF+jlk4ez+V3oRytUtWOGpkQek/lk9M5Yex+WU2QposeMQD09k+5xIP8AcPJCV7ItxEnyIeB/Sc+6QiHsIxBB6FY1a5YImR0PiA8gdvMI4fSu5dGAZQhRtd7H+0C138Q8Q9Qc/ElDGg7cQ4dWnVjy3HqEgzUUUQSKKKIN3IWjVmFo1VRatVamys1Uq7IMsvClNTdNLtBUqcmVjV41mde2ltkEiTyHIdz9F1/4esCX6jkpZw21krsuCMA29/VQzr5adFz8cn1vTgBEhZsWgXQgGuGJe4wm7xhL7mmgMCZCVXVnv0OfVG64K1e8OCzWs3lcP+JLXUA4bkAFclccOcHDHNfReKWkx4ts+HcnouRuaTgXa6bm+LwnUCHe5TkvXf47m55WnDrJjQ6o6CGNJzkF8fsuSe6Xbb/BN7ms/QWBph2TmAlttbOnxDmtRz+Wy30Is6ScWrXNIjBBkcxMZHcEclSytMSjTTA23TiFWqUmkAtHgcfDn/t1ObCeh5endY0ar2OluORBEgjo4c0TQcJLD7Lv9Lv0lC3DzmR4m4d35T99kyEvex7fCMjdhOW9Sx3Tt/8ASR3lIgzu33EdnDl8lo+oQZBIMyDMEHqFq2tr6CoBkYAqDmW8g7qNj0TBJWCya6Pv4hM7m3Dss9rm3Y+7l9+SWELJtDVP6od57/3bryGnYkdjke8fRUCiCX/LPVv9zfqoqKIN3IV2qgV2qqLVqrVVmqlVIyy4bKvZ28nstHMyi6DFy+bf8dfhx/TC0ZyGB8/NdJwgQuetTCdcNqLPiv7N+WenRscrgoai+VuCutyNCVhWatVnUCAV3NJCMcWnOyaVGoOrTWaC2/6tG65rij5BbHLHn1XXPppRe8MDj0Urm99K5365XC1ncvgtrW0JMmQPmugfwhkzusq1GNk5KV1KEc+MDZYver1Gody2ys96ly/LXjmIPTGPdGPRZuWT6ngI6H7/AHTJjcgctjkfRDNOZG61iQR6jz5/fZZhqDaXLtYkYf2/URy8+nXbcZA/ODsPEnk7Zw8+o81vWOPv3oV5nxc+fn19UE9dT6H0PhP0KoQRuF6MiOfL6LwOPVIPFF7q8vcFEG7gK7VQK7VVFqFjVcvXvhDOfJU965FfHnta02SUcwQFnbU8LWMrg1rtd+ZyC6TMI+yfBQVN3JbhkEGVrF5es6nZx0NvWRzHpDSqbZTW2fIXXnXXJrPBmpeEqoKhKowyqtQrwjSs3tCVAF7QhK7Ajq1OdkDcMKz0y6u2Evr00Zch0pdXDp5wl0cA3LAOaXV3gbIy9puSypRcn0w76hK9Y7B++RUNNeBv36FAUmF6d15C9nCZBbooRpz22PkiblCJGsRC9d1+5Xh2+H0++ygQSKLxRBu6CjnwqkrF7lvWuRjGe1Wo9a2jJKHDZKa2NNcfk1124zIKiAs2CVLh3Ja2zcKCrS2BlEVjhUYMrVzMJyFa1tKkhO7N2Fy7KmhyfWFYELp8OnP5s/04BUlZsKsSulzvXFZOcrErwhIMS5D1XIh7UNWYkYSqwboKswdEyczCCrU0AortHRKbxo5J5d08pVcUkAnc1ZOai6zIQrigB3NXkYV3rNxQA1wEEUdWGEC5I0Ci8XqCeqKKIN2Tiqlq9ByrkYUfLr2t4s8ilJuU5t2w1KbceJPGN8Khfa0APdLkbb4QI9tEl0QsN0cQtXHCypGQrauS1GKo+kD5ozh7tOChWvRtsyTKr4579J7vr2d0n4V5WVFq1hdblegLxwVlEBi5YPC3eVi9yOBi5D1QiaiEqnCAW3IlKLxpTaqDKFuWBAc5WmVk8I25b4kJXMJGDqLIhWeqhAYXGyXlH3TkAg0Xq8XqRJKiiiA7CnuiCMLBhyiW7Ll19urP0pREFPrdupqRjddBwrISzOnbwqu6el0qCpIR/GqWJCQ0KmYWNZ5eNZ12G1CvyW73pe0c1sHkpQUfQeCmVmcpFQBBXQWAkLo8aPk+jOk5bLBohW1rqc9aErNz1Nao9AVe9DvPNaOWNVyQeOfhBvctXPwhmuymGdUJfdvgIy5ck9++QgFVzU8SFe4ol4lD1BCRhXFeLQtCo5AB3ZQKKuXIVIPVFFEg9UXiiZuxbuiWbKKLj19unP0gT3hWyii14/sb+m/FfYK5RvtKKLPl/wCR4+jCnstKaiinGhVNPbHZRRdPiR8v0YqpUUXQ53gUcvFEwyqIaooogBqmyyaookGN3skVzsoogFjt1nUUURDCc147ZRRALLndYKKJB6ooogIooog3/9k='
},
{
title: '강아지',
src: 'data:image/jpeg;base64,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'
},
{
title: '햄스터',
src: 'data:image/jpeg;base64,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'
},
{
title: '돼지',
src: 'data:image/jpeg;base64,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'
},
{
title: '고슴도치',
src: 'data:image/jpeg;base64,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'
},
]
export default imageData;
imageData.js 파일을 생성하고 위와 같이 작성하자! 파일 경로가 길기 때문에 복사 붙여넣기 하면 된다.
import './App.css';
import React, { Component } from 'react';
import images from './imageData'
import Button from './Button'
class App extends Component {
state = {
index: 0
}
decreaseIndex = () => {
const nextIndex = this.state.index - 1
this.setState({index: (nextIndex < 0) ? images.length - 1 : nextIndex})
}
increaseIndex = () => {
const nextIndex = this.state.index + 1
this.setState({index: (nextIndex > images.length - 1) ? 0 : nextIndex})
}
render(){
const { index } = this.state
const { increaseIndex, decreaseIndex } = this
const path = images[index].src
const title = images[index].title
return (
<div className="App">
<div className="img-container">
<img src={path} alt={title}/>
</div>
<div className="control-btns">
<Button handleClick={decreaseIndex}>Prev</Button>
<Button handleClick={increaseIndex}>Next</Button>
</div>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
margin: 100px auto;
text-align: center;
}
.img-container{
width: 400px;
height: 400px;
overflow: hidden;
margin: 0 auto;
}
.img-container img{
width: 100%;
height: 100%;
}
.control-btns{
display: flex;
justify-content: center;
align-items: center;
margin-top: 20px;
}
App.css 파일을 위와 같이 작성하자!
import images from './imageData'
import Button from './Button'
이미지 데이터를 사용하기 위하여 해당 파일을 임포트한다. Button 컴포넌트를 사용하기 위하여 임포트한다.
state = {
index: 0
}
이미지 컬렉션(배열)에서 특정 이미지를 선택하기 위한 index 상태를 초기화한다.
decreaseIndex = () => {
const nextIndex = this.state.index - 1
this.setState({index: (nextIndex < 0) ? images.length - 1 : nextIndex})
}
사용자가 Prev 버튼을 클릭했을때 처리할 이벤트핸들러 함수이다. index 값을 1 감소시킨 다음, 0보다 작으면 이미지 컬렉션의 마지막 사진을 선택한다. 0보다 크거나 같으면 nextIndex 값에 해당하는 이전 사진을 선택한다.
increaseIndex = () => {
const nextIndex = this.state.index + 1
this.setState({index: (nextIndex > images.length - 1) ? 0 : nextIndex})
}
사용자가 Next 버튼을 클릭했을때 처리할 이벤트핸들러 함수이다. index 값을 1 증가시킨 다음, 이미지 컬렉션의 마지막 인덱스보다 크면 첫번째 사진을 선택한다. 그렇지 않으면 nextIndex 값에 해당하는 다음 사진을 선택한다.
const { index } = this.state
const { increaseIndex, decreaseIndex } = this
const path = images[index].src
const title = images[index].title
render 함수 안에서 index 상태를 조회한다. 해당 index 상태를 이용하여 이미지 컬렉션에서 특정 사진을 선택한다.
return (
<div className="App">
<div className="img-container">
<img src={path} alt={title}/>
</div>
<div className="control-btns">
<Button handleClick={decreaseIndex}>Prev</Button>
<Button handleClick={increaseIndex}>Next</Button>
</div>
</div>
);
선택된 이미지의 파일경로(path)와 타이틀(title) 값을 이용하여 특정 사진을 화면에 보여준다. 또한, 이전 사진을 보여주는 Prev 버튼과 다음 사진을 보여주는 Next 버튼을 만들고 각각에 해당하는 이벤트핸들러 함수를 연결한다.
버튼 이벤트를 이용하여 사이드바 메뉴를 만들어보자!
import './Sidebar.css'
function Sidebar({ open, children }){
return (
<div className={`sidebar ${open? 'open': ''}`}>
<div className="sidebar-menus">{children}</div>
</div>
)
}
export default Sidebar
Sidebar.js 파일을 위와 같이 작성하자!
Sidebar 컴포넌트는 open, children 을 props 로 전달받는다. open 은 불리언 값으로 true 이면 사이드바 메뉴가 나타나게 하고 false 이면 사라지게 한다. 사이드바의 width 는 기본적으로 30% 이다. position 을 absolute 로 설정하고 left 를 -30% (width 만큼) 으로 설정하면 사이드바 메뉴는 사라진다. open 이 true 이면 left 가 0 이 되어 사이드바 메뉴가 나타난다.
children 은 props 로 전달받는 메뉴들을 렌더링하기 위함이다.
.sidebar{
width: 30%;
height: 100vh;
/* border: 1px solid red; */
background: khaki;
position: absolute;
left: -30%;
top: 0;
transition: all 0.7s ease-out;
}
.open {
left: 0;
}
.sidebar-menus{
/* border: 1px solid black; */
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
Sidebar.css 파일을 위와 같이 작성하자!
import './App.css';
import React, { Component } from 'react';
import Button from './Button'
import Sidebar from './Sidebar'
class App extends Component {
state = {
toggle: false,
menus: [
{
icon: '♜',
title: 'HOME'
},
{
icon: '♞',
title: 'ABOUT'
},
{
icon: '☻',
title: 'SETTING'
},
{
icon: '♜',
title: 'HOME'
},
{
icon: '♞',
title: 'ABOUT'
},
{
icon: '☻',
title: 'SETTING'
}
]
}
toggleMenu = () => {
this.setState({toggle: !this.state.toggle})
}
render(){
const { toggle, menus } = this.state
return (
<div className="App">
<Button handleClick={this.toggleMenu}>Open sidebar</Button>
<Sidebar open={toggle}>
{menus.map( (menu, id) => {
return <div className="menu" key={id}>{menu.icon} {menu.title} </div>
})}
</Sidebar>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
margin: 100px auto;
text-align: center;
}
.menu{
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 2rem;
font-weight: bold;
color: tan;
border-bottom: 2px solid tan;
}
.menu:hover{
background-color: brown;
}
App.css 파일을 위와 같이 작성하자!
import Button from './Button'
import Sidebar from './Sidebar'
사이드바 메뉴를 열고 닫기 위하여 Button 컴포넌트를 재활용한다. 또한, Sidebar 컴포넌트도 임포트한다.
state = {
toggle: false,
menus: [
{
icon: '♜',
title: 'HOME'
},
{
icon: '♞',
title: 'ABOUT'
},
{
icon: '☻',
title: 'SETTING'
},
{
icon: '♜',
title: 'HOME'
},
{
icon: '♞',
title: 'ABOUT'
},
{
icon: '☻',
title: 'SETTING'
}
]
}
사이드바 메뉴를 열고 닫기 위하여 toggle state 를 이용한다. 웹 화면에 메뉴를 렌더링할때 사용할 menus state 를 선언한다.
toggleMenu = () => {
this.setState({toggle: !this.state.toggle})
}
사이드바 메뉴를 열고 닫기 위하여 toggle state 를 변경하는 이벤트핸들러 함수이다.
return (
<div className="App">
<Button handleClick={this.toggleMenu}>Open sidebar</Button>
<Sidebar open={toggle}>
{menus.map( (menu, id) => {
return <div className="menu" key={id}>{menu.icon} {menu.title} </div>
})}
</Sidebar>
</div>
);
버튼을 클릭하면 toggleMenu 이벤트핸들러 함수가 실행되면서 toggle state 를 변경한다. Sidebar 컴포넌트는 toggle state 에 따라 열리고 닫힌다. Sidebar 컴포넌트의 children props (컨텐츠)로 메뉴들이 전달되어 컴포넌트 내부에서 렌더링된다.
* 사용자 입력 처리하기
Input 창에서 사용자 입력을 받으려면 어떻게 해야 하는지 알아보자!
import './App.css';
import React, { Component } from 'react';
import Button from './Button'
class App extends Component {
state = {
id: '',
password: ''
}
handleChange = (e) => {
const { name, value } = e.target
console.log(name, value)
this.setState({ [name]: value}) // 주석처리하면 사용자 입력이 되지 않음
}
login = (e) => {
e.preventDefault() // 새로고침 방지
const { id, password } = this.state
alert(`
아래 정보로 로그인 하시겠어요?
- ID: ${id} / PASSWORD: ${password} -
`)
}
render(){
const { id, password } = this.state
return (
<div className="App">
<form>
<label>ID <input type="text" placeholder="TYPE YOUR ID ..." name="id" value={id} onChange={this.handleChange} autoFocus></input></label><br/><br/>
<label>PASSWORD <input type="password" placeholder="TYPE YOUR PASSWORD ..." name="password" value={password} onChange={this.handleChange}></input></label>
<div className="login-btn"><Button handleClick={this.login}>Login</Button></div>
</form>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
width: 300px;
clear: both;
margin: 200px auto;
}
.App label{
color:lightgreen;
font-weight: bold;
margin-left: 5px;
}
.App input {
width: 100%;
height: 30px;
clear: both;
border: 2px solid lightgreen;
border-radius: 15px;
outline: none;
padding: 10px;
color: lightgreen;
font-weight: bold;
font-size: 1rem;
}
.App input::placeholder{
color: lightgreen;
}
.login-btn{
width: 320px;
display: flex;
justify-content: flex-end;
}
App.css 파일을 위와 깉이 작성하자!
state = {
id: '',
password: ''
}
사용자가 입력하는 ID 와 PASSWORD 를 기억하기 위하여 state 를 초기화한다.
handleChange = (e) => {
const { name, value } = e.target
console.log(name, value)
this.setState({ [name]: value}) // 주석처리하면 사용자 입력이 되지 않음
}
사용자가 input 요소에 뭔가를 입력하면 handleChange 이벤트핸들러 함수가 실행된다. e.target 은 사용자가 입력중인 input 요소를 가리킨다. 사용자가 입력중인 input 의 name, value 속성을 조회한 다음 해당 state 를 변경한다.
예를 들어, 사용자가 ID 를 입력중이라면 name 은 'id' 이고 value 는 사용자가 입력한 값이다. 그럼 this.setState({ ['id']: 'your id' }) 와 같이 id state 를 변경한다. 객체의 프로퍼티가 문자열이면 대괄호([])를 사용해야 한다.
사용자가 PASSWORD 를 입력중이라면 name 은 'password' 이고 value 는 사용자가 입력한 값이다. 그럼 this.setState({ ['password']: 'your password' }) 와 같이 password state 를 변경한다.
만약 대괄호가 없으면 name 이라는 state 를 변경하는데, 현재는 name 이라는 state 도 없을뿐더러 name 이라는 state 값을 변경하는 것이 아니라 입력하는 입력창의 종류에 따라 해당하는 id, password 상태를 변경해야 하므로 대괄호가 반드시 필요하다.
render(){
const { id, password } = this.state
return (
<div className="App">
<form>
<label>ID <input type="text" placeholder="TYPE YOUR ID ..." name="id" value={id} onChange={this.handleChange} autoFocus></input></label><br/><br/>
<label>PASSWORD <input type="password" placeholder="TYPE YOUR PASSWORD ..." name="password" value={password} onChange={this.handleChange}></input></label>
<div className="login-btn"><Button handleClick={this.login}>Login</Button></div>
</form>
</div>
);
}
사용자가 입력창에 뭔가를 입력하면 onChange 이벤트에 의하여 handleChange 이벤트핸들러 함수가 실행된다. 이후에 state 가 변경되면 render 함수가 다시 호출되면서 변경된 id, password state 를 조회하고 해당 값들로 input 요소들의 value 값을 설정한다. 그런 다음 Login 버튼을 클릭하거나 엔터키를 누르면 alert 창이 뜨면서 내가 입력한 ID, PASSWORD 정보가 화면에 나타난다. 리액트에서는 form 안에 input 태그가 있으면 기본적으로 아무 설정을 하지 않아도 엔터키가 동작한다.
사용자 입력처리를 아래와 같이 작성해도 된다.
mport './App.css'
import React, { Component } from 'react'
import Button from './Button'
class App extends Component{
login = (e) => {
const inputs = document.querySelectorAll('input')
const id = inputs[0].value
const password = inputs[1].value
alert(`
아래 정보로 로그인 하시겠습니까?
- ID: ${id} / PASSWORD: ${password}
`)
}
render(){
return (
<div className='App'>
<form>
<label>ID
<input type="text" name="id" placeholder='아이디를 입력하세요' ></input>
</label>
<label>PASSWORD
<input type="password" name="password" placeholder='비밀번호를 입력하세요' ></input>
</label>
<div><Button handleClick={this.login}>로그인</Button></div>
</form>
</div>
)
}
}
export default App
이번에는 간단한 드롭다운 메뉴를 만들어보자!
import React from 'react';
// 리액트에서 자주 사용하는 배열 내장 메서드 : filter, map, reduce, concat
class App extends React.Component {
state = { value: '' }
handleChange = (e) => {
console.log(e.target.value)
this.setState({ value: e.target.value })
}
render(){
return (
<select value={this.state.value} onChange={this.handleChange}>
<option >first</option>
<option >second</option>
<option >third</option>
</select>
)
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
state = { value: '' }
드롭다운의 현재 메뉴를 저장하기 위한 value 상태를 정의한다.
handleChange = (e) => {
console.log(e.target.value) // 사용자가 선택한 메뉴
this.setState({ value: e.target.value }) // 사용자가 선택한 메뉴로 드롭다운 변경
}
사용자가 드롭다운에서 특정 메뉴를 선택하면 handleChange 이벤트핸들러 함수가 실행된다.
<select value={this.state.value} onChange={this.handleChange}>
<option >first</option>
<option >second</option>
<option >third</option>
</select>
select, option 태그는 HTML 에서 드롭다운을 쉽게 구현하게 해준다. 사용자가 드롭다운에서 특정 메뉴를 선택하면 onChange 이벤트가 발생한다. 사용자가 선택한 메뉴를 this.state.value 로 조회한 다음 select 엘리먼트의 value 속성으로 설정하여 메뉴를 업데이트한다.
이번에는 option 에 value 속성이 지정된 경우의 드롭다운 메뉴를 만들어보자!
import './App.css';
import React, { Component } from 'react';
class App extends Component {
state = {
selectedValue: ''
}
selectItem = (e) => {
console.log(e.target.value)
this.setState({selectedValue: e.target.value})
}
render(){
const { selectedValue } = this.state
return (
<div className="App">
<select value={selectedValue} onChange={this.selectItem}>
<option value="서울" >Seoul</option>
<option value="대구" >Deagu</option>
<option value="부산" >Busan</option>
</select>
<p>{selectedValue}</p>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
<div className="App">
<select value={selectedValue} onChange={this.selectItem}>
<option value="서울" >Seoul</option>
<option value="대구" >Deagu</option>
<option value="부산" >Busan</option>
</select>
<p>{selectedValue}</p>
</div>
기존 예제와 거의 동일하지만 이번에는 option 에 value 속성이 설정되어 있다. 사용자가 드롭다운에서 특정 메뉴를 선택하면 value 속성값을 조회한 다음 p 태그에 보여준다.
만약 서버에서 가져온 메뉴를 화면에 렌더링해야 한다면 어떻게 하면 될까?
import './App.css';
import React, { Component } from 'react';
// 서버에서 가져온 메뉴 데이터
const menus = [
{ value: "서울", text: 'Seoul'},
{ value: "대구", text: 'Deagu'},
{ value: "부산", text: 'Busan'},
]
class App extends Component {
state = {
selectedValue: ''
}
selectItem = (e) => {
console.log(e.target.value)
this.setState({selectedValue: e.target.value})
}
render(){
const { selectedValue } = this.state
return (
<div className="App">
<select value={selectedValue} onChange={this.selectItem}>
{menus.length !== 0 && menus.map( (menu, id) => {
return <option key={id} value={menu.value}>{menu.text}</option>
})}
</select>
<p>{selectedValue}</p>
</div>
);
}
}
export default App;
컴포넌트 외부에 menus 라는 변수로 가상의 서버 데이터를 정의한다. 객체들의 배열로 데이터가 넘어올 것으로 예상된다. 배열의 map 메서드로 option 엘리먼트를 데이터 갯수만큼 생성해주면 된다.
https://leafletjs.com/reference.html#marker
위의 드롭다운 예제를 활용하여 사용자가 특정 지역을 선택할때 지도에 표시해보자!
<!-- 지도 라이브러리 CDN 으로 추가하기 -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossorigin=""></script>
public 폴더의 index.html 파일에 위 코드를 추가하자!
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<!-- 지도 라이브러리 CDN 으로 추가하기 -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossorigin=""></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
지도 라이브러리를 CDN 으로 추가해준다.
import './App.css';
import React, { Component } from 'react';
// 서버에서 넘어온 가상의 메뉴 데이터
const coordinates = [
{
"name": "서울 강남",
"coordinate": [
37.497944,
127.027618
]
},
{
"name": "대구 동성로",
"coordinate": [
35.865467,
128.593369
]
},
{
"name": "부산 해운대",
"coordinate": [
35.1884,
129.166957
]
},
]
class App extends Component {
state = {
selectedValue: '',
map: null ,
marker: null,
info: ''
}
// 사용자가 선택한 위치정보 파싱
decomposeArgs = (args) => {
const { name, coordinate } = args
const [ lat, lon ] = coordinate // 사용자가 선택한 지역의 위치정보(위도/경도) 조회하기
return [ lat, lon, name ] // 위치정보 반환하기 (위도/경도/지역명)
}
// 사용자 선택한 위치 파싱후 지도에 마커 표시
display = (map, marker, loc) => {
const [ lat, lon, name ] = this.decomposeArgs(loc)
this.displayLocation(lat, lon, name, map, marker) // 사용자가 선택한 위치를 지도에 표시하기
}
// 지도에 사용자가 선택한 위치정보 표시
displayLocation = (lat, lon, name, mapObj, marker) => {
const map = mapObj.setView([lat, lon], 13) // 지도 초기 설정
window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { // 지도 타일맵 스타일 설정
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map)
marker.setLatLng([lat, lon]) // 마커 위치 설정
.bindPopup(name) // 마커에 대한 말풍선 표시
.openPopup()
this.setState({ // 화면에 위치정보(위도/경도/지역명) 보여주기
info: `
지역: ${name}
위치: ${lat} (위도) / ${lon} (경도)
`
})
}
selectItem = (e) => {
const { map, marker } = this.state
const selectedLocation = coordinates[e.target.selectedIndex] // 사용자가 선택한 위치정보 조회하기
console.log(selectedLocation)
this.display(map, marker, selectedLocation) // 사용자가 선택한 위치정보로 지도에 마커 표시하기
this.setState({selectedValue: e.target.value}) // 드롭다운 메뉴 변경
}
componentDidMount(){
const map = window.L.map('map') // 지도 객체 조회하기
const marker = window.L.marker([0, 0]).addTo(map) // 마커 객체 조회하기
const firstLocation = coordinates[0] // 초기 위치정보 조회하기
this.display(map, marker, firstLocation) // 초기 위치정보로 지도에 마커 표시하기
this.setState({map, marker}) // 초기 렌더링시 한번만 정의하기
}
render(){
const { selectedValue, info } = this.state
return (
<div className="App">
<select value={selectedValue} onChange={this.selectItem}>
{coordinates.length !== 0 && coordinates.map( (coord, id) => {
return <option key={id} value={coord.name}>{coord.name}</option>
})}
</select>
<div id="map"></div>
<p>{info}</p>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
width: 300px;
clear: both;
margin: 200px auto;
}
#map { height: 400px; }
App.css 파일을 위와 같이 작성하자!
지도가 화면에 제대로 표시되지 않으면 CSS 에서 id 값이 map 인 셀렉터에 제대로 너비, 높이가 적용이 되지 않는 문제이니 인라인 스타일로 직접 아래와 같이 스타일을 설정해주면 된다.
<div id="map" style={{ width: '400px', height: '400px' }}></div>
만약 개발자 콘솔에 Map 객체가 다시 초기화되었다는 에러가 발생하면 리액트 프로젝트에서 렌더링이 2번 발생하는 경우이니 아래와 같이 src > index.js 파일에서 <React.StrictMode> </React.StricMode> 컴포넌트를 제거하자. 해당 컴포넌트는 초기 렌더링이 2번 발생시켜서 리액트 코드가 예상대로 동작되지 않게 하는 주원인인다.
<React.StrictMode>
<App />
</React.StrictMode>
리액트로 간단한 체크박스 기능을 구현해보자!
import './App.css';
import React, { Component } from 'react';
class App extends Component {
state = {
selectedItems: [],
}
selectItem = (e) => {
console.log(e.target) // 선택한 인풋창 출력
console.log(e.target.value, e.target.checked) // 선택한 인풋창 속성 출력
const { selectedItems } = this.state // 사용자가 선택한 음식 배열
if(!selectedItems.includes(e.target.value)){ // 사용자가 선택한 음식이 배열에 존재하지 않는 경우
this.setState({selectedItems: [...selectedItems, e.target.value]}) // 선택한 음식을 배열에 추가하기
}else{
this.setState({selectedItems: selectedItems.filter(item => item !== e.target.value)}) // 선택한 음식을 배열에서 제거하기
}
}
render(){
const { selectedItems } = this.state
return (
<div className="App">
<input type="checkbox" onChange={this.selectItem} value="짜장면"/><span className={selectedItems.includes("짜장면") ? 'active': ''}>짜장면</span>
<input type="checkbox" onChange={this.selectItem} value="짬뽕" /><span className={selectedItems.includes("짬뽕") ? 'active': ''}>짬뽕</span>
<input type="checkbox" onChange={this.selectItem} value="탕수육" /><span className={selectedItems.includes("탕수육") ? 'active': ''}>탕수육</span>
<h2>사용자가 선택한 음식</h2>
<h3>{selectedItems.length !== 0 ? selectedItems.join(' ') : '먹고 싶은 음식을 선택하세요 !'}</h3>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
width: 300px;
clear: both;
margin: 200px auto;
}
.active{
background: yellow;
text-decoration: line-through;
}
App.css 파일을 위와 같이 작성하자!
state = {
selectedItems: [],
}
체크박스로 다수의 아이템을 선택하기 위하여 selectedItems 배열을 정의한다.
const { selectedItems } = this.state
사용자가 선택한 음식 리스트를 조회한다.
<input type="checkbox" onChange={this.selectItem} value="짜장면"/><span className={selectedItems.includes("짜장면") ? 'active': ''}>짜장면</span>
<input type="checkbox" onChange={this.selectItem} value="짬뽕" /><span className={selectedItems.includes("짬뽕") ? 'active': ''}>짬뽕</span>
<input type="checkbox" onChange={this.selectItem} value="탕수육" /><span className={selectedItems.includes("탕수육") ? 'active': ''}>탕수육</span>
input 요소에 onChange 이벤트와 selectItem 이벤트핸들러를 연결한다. includes 메서드를 활용하여 selectedItems 배열에 해당 음식이 존재하면 span 요소의 스타일에 active 를 적용한다. 그렇지 않으면 아무런 스타일도 적용하지 않는다.
<h2>사용자가 선택한 음식</h2>
<h3>{selectedItems.length !== 0 ? selectedItems.join(' ') : '먹고 싶은 음식을 선택하세요 !'}</h3>
selectedItems 배열에 아무런 음식도 없으면 '먹고 싶은 음식을 선택하세요' 라는 문구를 보여주고, 사용자가 음식을 하나라도 선택하면, 선택한 음식 리스트를 화면에 보여준다.
selectItem = (e) => {
const { selectedItems } = this.state // 사용자가 선택한 음식 배열
if(!selectedItems.includes(e.target.value)){ // 사용자가 선택한 음식이 배열에 존재하지 않는 경우
this.setState({selectedItems: [...selectedItems, e.target.value]}) // 선택한 음식을 배열에 추가하기
}else{
this.setState({selectedItems: selectedItems.filter(item => item !== e.target.value)}) // 선택한 음식을 배열에서 제거하기
}
}
사용자가 체크박스를 클릭하면 selectItem 이 실행된다. 맨 먼저 selectedItems 상태를 조회한다. selectedItems 배열에는 사용자가 현재까지 선택한 음식 리스트가 저장되어 있다. 사용자가 방금 선택한 음식은 e.target.value 이므로 해당 값이 selectedItems 배열에 존재하면 제거하고, 해당 값이 selectedItems 배열에 존재하지 않으면 추가한다.
만약 음식 데이터를 서버에서 불러온다면 어떻게 하면 될까?
import './App.css';
import React, { Component } from 'react';
// 서버에서 가져온 음식 데이터
const foods = ["짜장면", "짬뽕", "탕수육"]
class App extends Component {
state = {
selectedItems: [],
}
selectItem = (e) => {
console.log(e.target) // 선택한 인풋창 출력
console.log(e.target.value, e.target.checked) // 선택한 인풋창 속성 출력
const { selectedItems } = this.state // 사용자가 선택한 음식 배열
if(!selectedItems.includes(e.target.value)){ // 사용자가 선택한 음식이 배열에 존재하지 않는 경우
this.setState({selectedItems: [...selectedItems, e.target.value]}) // 선택한 음식을 배열에 추가하기
}else{
this.setState({selectedItems: selectedItems.filter(item => item !== e.target.value)}) // 선택한 음식을 배열에서 제거하기
}
}
render(){
const { selectedItems } = this.state
return (
<div className="App">
{foods.map((food, id) => {
return (
<div key={id}>
<input type="checkbox" onChange={this.selectItem} value={food}/>
<span className={selectedItems.includes(food) ? 'active': ''}>{food}</span>
</div>
)
})}
<h2>사용자가 선택한 음식</h2>
<h3>{selectedItems.length !== 0 ? selectedItems.join(' ') : '먹고 싶은 음식을 선택하세요 !'}</h3>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 수정하자!
// 서버에서 가져온 음식 데이터
const foods = ["짜장면", "짬뽕", "탕수육"]
foods 변수가 서버에서 불러온 음식 데이터라고 가정한다.
{foods.map((food, id) => {
return (
<div key={id}>
<input type="checkbox" onChange={this.selectItem} value={food}/>
<span className={selectedItems.includes(food) ? 'active': ''}>{food}</span>
</div>
)
})}
map 메서드를 이용하여 foods 배열에서 각각의 음식 정보를 조회한 다음 return 문 안에 존재하는 jsx 엘리먼트로 변환하면 된다. 이러한 jsx 엘리먼트는 다시 Food 컴포넌트로 만들고 App 컴포넌트에서 불러와서 사용해도 된다.
코드 리팩토링을 해보자!
import React, { Component } from 'react'
import './App.css'
class App extends Component{
state = {
selectedItems: [],
}
selectItem = (e) => {
console.log(e.target) // 선택한 인풋창 출력
console.log(e.target.value, e.target.checked) // 선택한 인풋창 속성 출력
const { selectedItems } = this.state // 사용자가 선택한 음식 배열
if(e.target.checked){ // 사용자가 선택한 음식이 배열에 존재하지 않는 경우
this.setState({selectedItems: [...selectedItems, e.target.value]}) // 선택한 음식을 배열에 추가하기
}else{
this.setState({selectedItems: selectedItems.filter(item => item !== e.target.value)}) // 선택한 음식을 배열에서 제거하기
}
}
render(){
const { selectedItems } = this.state
return (
<div className="App">
<label><input type="checkbox" onChange={this.selectItem} value="짜장면"></input><span className={selectedItems.includes("짜장면") ? "active" : ""}>짜장면</span></label>
<label><input type="checkbox" onChange={this.selectItem} value="짬뽕"></input><span className={selectedItems.includes("짬뽕") ? "active" : ""}>짬뽕</span></label>
<label><input type="checkbox" onChange={this.selectItem} value="탕수육"></input><span className={selectedItems.includes("탕수육") ? "active" : ""}>탕수육</span></label>
<h2>사용자가 선택한 음식</h2>
<h3>{selectedItems.length !== 0 ? selectedItems.join(' ') : '먹고 싶은 음식을 선택하세요 !'}</h3>
</div>
)
}
}
export default App
이번에는 간단한 라디오버튼 예제를 구현해보자!
import './App.css';
import React, { Component } from 'react';
class App extends Component {
state = {
selectedValue: '짜장면',
}
selectItem = (e) => {
console.log(e.target.value)
console.log(e.target.checked)
this.setState({selectedValue : e.target.value})
}
render(){
const { selectedValue } = this.state
return (
<div className="App">
<input type="radio" onChange={this.selectItem} value="짜장면" checked={selectedValue === "짜장면"}/><span style={{background: `${selectedValue === '짜장면' ? 'yellow' : ''}`}}>짜장면</span>
<input type="radio" onChange={this.selectItem} value="짬뽕" checked={selectedValue === "짬뽕"}/><span style={{background: `${selectedValue === '짬뽕' ? 'yellow' : ''}`}}>짬뽕</span>
<input type="radio" onChange={this.selectItem} value="탕수육" checked={selectedValue === "탕수육"}/><span style={{background: `${selectedValue === '탕수육' ? 'yellow' : ''}`}}>탕수육</span>
<h2>사용자가 선택한 음식</h2>
<h3>{selectedValue}</h3>
</div>
);
}
}
export default App;
라디오버튼은 체크박스와 다르게 하나만 선택 가능하다.
<span style={{background: `${selectedValue === '짜장면' ? 'yellow' : ''}`}}>짜장면</span>
사용자가 선택한 음식에 따라 span 요소의 배경색을 적용해준다.
만약 서버에서 음식 데이터를 불러온다면 아래와 같이 코드를 수정하면 된다.
import './App.css';
import React, { Component } from 'react';
// 서버에서 불러온 음식 데이터
const foods = ["짜장면", "짬뽕", "탕수육"]
class App extends Component {
state = {
selectedValue: foods[0],
}
selectItem = (e) => {
console.log(e.target.value)
console.log(e.target.checked)
this.setState({selectedValue : e.target.value})
}
render(){
const { selectedValue } = this.state
return (
<div className="App">
{foods.map((food, id) => {
return (
<div key={id}>
<input type="radio" onChange={this.selectItem} value={food} checked={selectedValue === food}/>
<span style={{background: `${selectedValue === food ? 'yellow' : ''}`}}>{food}</span>
</div>
)
})}
<h2>사용자가 선택한 음식</h2>
<h3>{selectedValue}</h3>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 수정하자!
// 서버에서 불러온 음식 데이터
const foods = ["짜장면", "짬뽕", "탕수육"]
서버에서 위와 같이 음식 데이터를 불러온다고 가정하자!
state = {
selectedValue: foods[0],
}
초기 렌더링시에 보여줄 음식 데이터를 설정한다.
{foods.map((food, id) => {
return (
<div key={id}>
<input type="radio" onChange={this.selectItem} value={food} checked={selectedValue === food}/>
<span style={{background: `${selectedValue === food ? 'yellow' : ''}`}}>{food}</span>
</div>
)
})}
map 메서드를 이용하여 foods 배열에서 음식 데이터를 꺼낸 다음 jsx 엘리먼트로 바꿔준다.
* 파일 업로드 처리하기
import './App.css';
import React, { Component } from 'react';
import Button from './Button'
class App extends Component {
constructor(props){
super(props)
this.state = {
fileName: '',
imgSrc: ''
}
this.fileInput = React.createRef() // ref 생성하기
}
isValid = (type) => {
return type === 'image'
}
handleChange = (e) => {
console.log(e.target.files[0])
const file = e.target.files[0]
const imgSrc = URL.createObjectURL(file)
if(this.isValid(file.type.split('/')[0])){
this.setState({ fileName: file.name, imgSrc })
}else{
this.setState({ fileName: 'File is not valid type !', imgSrc: ''})
}
}
openFileWindow = () => {
this.fileInput.current.click() // ref 사용하기
}
render(){
const { fileName, imgSrc } = this.state
return (
<div className="App">
<h1>{fileName}</h1>
{imgSrc !== '' && <img src={imgSrc} alt="preview-img" width="300px" height="400px"></img> }
<input className="Upload" type="file" onChange={this.handleChange} ref={this.fileInput} accept="image/*"></input>
<Button handleClick={this.openFileWindow}>Upload</Button>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
.App{
width: 30%;
margin: 200px auto;
}
.Upload{
display: none;
}
App.css 파일을 위와 같이 작성하자!
constructor(props){
super(props)
this.state = {
fileName: '',
imgSrc: ''
}
this.fileInput = React.createRef() // ref 생성하기
}
업로드하는 파일의 이름을 보여주기 위한 fileName 과 파일의 경로를 조회하기 위한 imgSrc 상태를 선언한다. 요소참조에서 언급했듯이 브라우저의 기본적인 파일 입력창 디자인은 좋지 않아서 숨기고 버튼을 클릭했을때 파일 입력창이 열리도록 하기 위하여 요소 참조(ref)를 사용한다.
isValid = (type) => {
return type === 'image'
}
사용자가 이미지 파일만 업로드하도록 제한하기 위하여 파일의 타입을 검사하는 함수이다.
handleChange = (e) => {
console.log(e.target.files[0])
const file = e.target.files[0]
const imgSrc = URL.createObjectURL(file)
if(this.isValid(file.type.split('/')[0])){
this.setState({ fileName: file.name, imgSrc })
}else{
this.setState({ fileName: 'File is not valid type !', imgSrc: ''})
}
}
사용자가 파일을 선택하면 실행되는 이벤트핸들러 함수이다. e.target.files[0] 는 사용자가 선택한 파일의 정보를 담고 있다. 파일의 이름, 타입, 크기, 수정된 날짜 등을 조회할 수 있다. URL.createObjectURL 메서드는 파일 데이터로부터 Blob 형태의 이미지 경로를 생성한다.
업로드한 파일이 이미지인 경우 fileName 과 imgSrc 상태를 변경하고 웹화면에 파일 이름과 이미지를 보여준다. 그렇지 않으면 fileName 에 파일 타입이 잘못되었다는 메세지를 보여주고, imgSrc 를 빈 문자열('')로 초기화하여 이미지를 보여주지 않도록 한다.
openFileWindow = () => {
this.fileInput.current.click() // ref 사용하기
}
Upload 버튼을 클릭하면 실행되는 이벤트핸들러 함수이다. 말그대로 요소 참조로 설정한 input 요소를 클릭하여 파일 입력창을 연다.
render(){
const { fileName, imgSrc } = this.state
return (
<div className="App">
<h1>{fileName}</h1>
{imgSrc !== '' && <img src={imgSrc} alt="preview-img" width="300px" height="400px"></img> }
<input className="Upload" type="file" onChange={this.handleChange} ref={this.fileInput} accept="image/*"></input>
<Button handleClick={this.openFileWindow}>Upload</Button>
</div>
);
}
fileName 과 imgSrc 상태를 조회한 다음 해당 값들로 파일 이름과 이미지를 웹 화면에 보여준다. 앤드 연산자(&&)를 사용하여 imgSrc 가 빈 문자열('')이 아니면 이미지를 보여주도록 한다. 빈 문자열이면 앤드 연산자 이후는 실행되지 않는다. 즉, 화면에 이미지가 보이지 않는다.
Upload 버튼을 클릭하면 openFileWindow 함수가 실행된다. 사용자가 파일을 선택하면 input 요소의 onChange 이벤트가 발생하면서 handleChange 이벤트핸들러 함수가 실행된다.
* 페이지 스크롤링하기
리액트에서 페이지 스크롤링 기능을 구현해보자!
import ScrollComponent from "./ScrollComponent";
export default function App() {
return (
<div className="App">
<ScrollComponent />
</div>
);
}
App.js 파일을 위와 같이 작성하자! App 컴포넌트에서 ScrollComponent 를 불러온다.
import React from "react";
import "./ScrollComponent.css";
const ScrollComponent = () => {
const setPosition = (e) => {
document
.getElementById(`content-${e.target.id}`)
.scrollIntoView({ behavior: "smooth" });
};
return (
<>
<div className="ScrollComponent-tabs">
<div className="ScrollComponent-tab" id="0" onClick={setPosition}>
컨텐츠 1
</div>
<div className="ScrollComponent-tab" id="1" onClick={setPosition}>
컨텐츠 2
</div>
<div className="ScrollComponent-tab" id="2" onClick={setPosition}>
컨텐츠 3
</div>
<div className="ScrollComponent-tab" id="3" onClick={setPosition}>
컨텐츠 4
</div>
</div>
<div className="ScrollComponent-container">
<div className="ScrollComponent-content" id="content-0"><span>햇빛 좋은 날 여행을 떠나보자 !</span></div>
<div className="ScrollComponent-content" id="content-1"><span>터키에서 열기구 타고 하늘로 둥둥 ~</span></div>
<div className="ScrollComponent-content" id="content-2"><span>푸른 나무를 만끽하며 드라이브 떠나볼까?</span></div>
<div className="ScrollComponent-content" id="content-3"><span>강변에 앉아서 평온함을 느끼는 중 ...</span></div>
</div>
</>
);
};
export default ScrollComponent;
ScrollComponent.js 파일을 위와 같이 작성하자!
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap');
.ScrollComponent-tabs {
display: flex;
/* flex-wrap: wrap; */
width: 100%;
position: fixed;
z-index: 1;
left: 0;
right: 0;
top: 0;
}
.ScrollComponent-tab {
flex: 1 1 25%;
height: 5rem;
background: rgba(255, 255, 255, .2);
/* border: 1px solid gray; */
margin-right: 0.1rem;
cursor: pointer;
color: white;
font-weight: bold;
font-size: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.2s linear;
}
.ScrollComponent-tab:hover {
background: rgba(255, 255, 255, .5);
}
.ScrollComponent-container {
max-width: 100%;
min-height: 100vh;
background-color: black;
}
.ScrollComponent-content {
width: 100%;
min-height: 100vh;
background-size: cover;
position: relative;
}
.ScrollComponent-content span{
color: white;
opacity: 0.8;
font-size: 3.5rem;
font-weight: bold;
position: absolute;
text-align: left;
top: 50%;
left: 5%;
letter-spacing: 2px;
font-family: 'Nanum Pen Script', cursive;
}
.ScrollComponent-content:nth-child(1) {
background-image: url("https://c.wallhere.com/photos/ea/96/1920x1080_px_clouds_landscape_nature_Trees-706530.jpg!d");
}
.ScrollComponent-content:nth-child(2) {
background-image: url("https://prod-virtuoso.dotcmscloud.com/dA/188da7ea-f44f-4b9c-92f9-6a65064021c1/previewImage/PowerfulReasons_hero.jpg");
}
.ScrollComponent-content:nth-child(3) {
background-image: url("http://file.instiz.net/data/file/20121125/5/6/0/5609249762358c2bcb65a46580549c99");
}
.ScrollComponent-content:nth-child(4) {
background-image: url("https://www.institutostrom.org/wp-content/uploads/2018/04/NZ.jpg");
}
ScrollComponent.css 파일을 위와 같이 작성하자!
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap');
구글폰트에서 마음에 드는 폰트를 골라 임포트한다.
font-family: 'Nanum Pen Script', cursive;
해당 폰트를 스타일 코드에 적용한다.
{/* 스크롤 버튼 그룹 */}
<div className="ScrollComponent-tabs">
<div className="ScrollComponent-tab" id="0" onClick={setPosition}>
컨텐츠 1
</div>
<div className="ScrollComponent-tab" id="1" onClick={setPosition}>
컨텐츠 2
</div>
<div className="ScrollComponent-tab" id="2" onClick={setPosition}>
컨텐츠 3
</div>
<div className="ScrollComponent-tab" id="3" onClick={setPosition}>
컨텐츠 4
</div>
</div>
페이지를 스크롤하기 위한 버튼 4개를 정의한다. 각각의 버튼에는 id 값이 0 ~3 으로 설정되어 있고, onClick 이벤트에 setPosition 이라는 이벤트핸들러가 연결되어 있다. 각각의 버튼을 클릭하면 해당 페이지로 스크롤링된다.
{/* 페이지 그룹 */}
<div className="ScrollComponent-container">
<div className="ScrollComponent-content" id="content-0"><span>햇빛 좋은 날 여행을 떠나보자 !</span></div>
<div className="ScrollComponent-content" id="content-1"><span>터키에서 열기구 타고 하늘로 둥둥 ~</span></div>
<div className="ScrollComponent-content" id="content-2"><span>푸른 나무를 만끽하며 드라이브 떠나볼까?</span></div>
<div className="ScrollComponent-content" id="content-3"><span>강변에 앉아서 평온함을 느끼는 중 ...</span></div>
</div>
버튼을 클릭할때 보여줄 페이지 그룹이다. 총 4개의 페이지가 정의되어 있고, id 값은 접두어 content 뒤에 0 ~ 3 으로 설정되어 있다. 이렇게 한 이유는 id 값이 0 ~ 3 인 각 버튼을 클릭할때 접두어를 추가한 특정 페이지를 조회하기 위함이다.
const setPosition = (e) => {
document
.getElementById(`content-${e.target.id}`)
.scrollIntoView({ behavior: "smooth" });
};
버튼을 클릭하면 실행되는 이벤트핸들러이다. 사용자가 클릭한 버튼의 id 값(e.target.id)을 조회하고, 앞에 접두어 content- 를 붙여서 스크롤링할 페이지를 선택한다. 해당 페이지를 scrollIntoView 메서드를 이용하여 스크롤링한다.
https://unsplash.com/documentation#search-photos
이번에는 무한 스크롤링 (infinite scrolling) 기능을 구현해보자!
REACT_APP_UNSPLASH_API_KEY = OSPiM6F8Vv_x7QI0-MRdTHgufujqAWEInsZbfIx6bnU
package.json 이 위치한 곳에 .env 파일(환경변수 설정 파일)을 생성하고, 위와 같이 자신의 unsplash API 키를 등록한다. 웹사이트를 만들때 보안상 민감함 정보(비밀번호, API 키 등)는 .env 파일에 정의한다. 리액트에서 환경변수를 정의할때는 변수 앞에 접두어로 REACT_APP_ 을 붙여줘야 한다. 리액트에서는 컴포넌트에서 process.env.REACT_APP_UNSPLASH_API_KEY 와 같이 사용하면 된다. 이렇게 하면 해커는 .env 파일에 접근하지 못하므로 보안이 보장된다.
주의할점은 .env 파일은 민감한 정보이므로 .gitignore 에 추가해서 github 에 업로드하지 않도록 한다. 또한, .env 파일을 변경했으면 ctrl + c 하고 npm start 해서 프로그램을 한번 껐다 켜야 process.env 가 동작한다.
import React, { Component } from 'react';
import './App.css'
class App extends Component {
pageNum = 1
state = {
keyword: '',
photos: []
}
getPhotos = async () => { // unsplash API 에서 사진 리스트 가져오기
const data = await fetch(`https://api.unsplash.com/search/photos?page=${this.pageNum}&query=${this.state.keyword}&client_id=${process.env.REACT_APP_UNSPLASH_API_KEY}&per_page=20`)
const dataJson = await data.json()
return dataJson.results
}
handleChange = (e) => {
this.setState({ keyword: e.target.value }) // 사용자 검색어로 keyword 상태 업데이트하기
}
searchPhotos = async (e) => { // 검색 버튼 클릭시 사진 리스트 검색하기
e.preventDefault() // 새로고침 방지하기
const photosContainer = document.querySelector('.App-photo-container')
photosContainer.scrollTop = 0 // 스크롤 위치 초기화하기
const photos = await this.getPhotos()
console.log(photos)
this.setState({ photos }) // photos 상태 업데이트하고 리렌더링하기
}
handleScroll = async () => { // 스크롤 이벤트 처리하기
const photosContainer = document.querySelector('.App-photo-container')
if(photosContainer.scrollTop + photosContainer.clientHeight === photosContainer.scrollHeight){ // 스크롤바를 맨 아래로 내렸을때
console.log('bottom of page !')
this.pageNum++ // 다음 페이지 조회하기
const photos = await this.getPhotos()
console.log([...this.state.photos, ...photos])
this.setState({ photos: [...this.state.photos, ...photos] }) // 기존 photos 배열에 현재 불러온 사진 리스트 추가하기
}
}
componentDidMount(){
document.querySelector('.App-photo-container').addEventListener('scroll', this.handleScroll) // 포토박스에 스크롤 이벤트 등록하기
}
componentWillUnmount(){
document.querySelector('.App-photo-container').removeEventListener('scroll', this.handleScroll) // 스크롤 이벤트 해제하기
}
render() {
// console.log(process.env.REACT_APP_UNSPLASH_API_KEY)
const { photos, keyword } = this.state
return (
<div className='App'>
<form className='App-search-container'>
<input type="text" value={keyword} onChange={this.handleChange} placeholder="검색어 입력"/>
<button type="submit" onClick={this.searchPhotos}>검색</button>
</form>
<div className='App-photo-container'>
{photos.length === 0 ?
<div>원하시는 사진을<br/> 검색창에서 찾아보세요 !</div>
: photos.map(photo => <img
key={photo.id}
className="App-photo-item"
src={photo.urls.small}
alt={photo.alt_description}/>)}
</div>
</div>
);
}
}
export default App;
App.js 파일을 위와 같이 작성하자!
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap');
.App {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
font-family: 'Nanum Pen Script', cursive;
color: lightblue;
width: 60%;
margin: 0 auto;
}
.App-photo-container div{
font-size: 5rem;
margin-bottom: 30px;
text-align: right;
margin-left: auto;
}
.App-search-container{
display: flex;
justify-content: center;
align-items: center;
width: 500px;
margin-top: 50px;
margin-bottom: 30px ;
}
.App-search-container input{
flex: 1;
outline: none;
border: 2px solid lightblue;
border-radius: 5px;
height: 50px;
margin-right: 3px;
padding-left: 10px;
box-sizing: border-box;
color: lightblue;
font-size: 1.3rem;
}
.App-search-container input::placeholder{
color: lightblue;
}
.App-search-container button{
all: unset;
background: lightblue;
color: white;
font-size: 2rem;
font-weight: bold;
padding: 5px;
width: 80px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
.App-photo-container{
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 700px;
overflow: auto;
}
.App-photo-item{
flex: 1 0 25%;
height: 200px;
}
.App-photo-container::-webkit-scrollbar {
width: 13px; /* 스크롤바의 너비 */
}
.App-photo-container::-webkit-scrollbar-thumb {
height: 30%; /* 스크롤바의 길이 */
background: lightblue; /* 스크롤바의 색상 */
border-radius: 10px;
}
.App-photo-container::-webkit-scrollbar-track {
background: rgba(33, 122, 244, .1); /*스크롤바 뒷 배경 색상*/
}
App.css 파일을 위와 같이 작성하자!
이제 검색어를 입력하고 검색 버튼을 클릭한 다음 스크롤링을 해보자!
DOM 이 렌더링된 이후(componentDidMount)에 스크롤 이벤트를 등록해도 되지만 아래와 같이 엘리먼트에 직접 onScroll 이벤트를 연결해도 된다.
import React, { Component } from 'react';
import './App.css'
class App extends Component {
pageNum = 1
state = {
keyword: '',
photos: []
}
getPhotos = async () => { // unsplash API 에서 사진 리스트 가져오기
const data = await fetch(`https://api.unsplash.com/search/photos?page=${this.pageNum}&query=${this.state.keyword}&client_id=${process.env.REACT_APP_UNSPLASH_API_KEY}&per_page=20`)
const dataJson = await data.json()
return dataJson.results
}
handleChange = (e) => {
this.setState({ keyword: e.target.value }) // 사용자 검색어로 keyword 상태 업데이트하기
}
searchPhotos = async (e) => { // 검색 버튼 클릭시 사진 리스트 검색하기
e.preventDefault() // 새로고침 방지하기
const photosContainer = document.querySelector('.App-photo-container')
photosContainer.scrollTop = 0 // 스크롤 위치 초기화하기
const photos = await this.getPhotos()
console.log(photos)
this.setState({ photos }) // photos 상태 업데이트하고 리렌더링하기
}
handleScroll = async () => { // 스크롤 이벤트 처리하기
const photosContainer = document.querySelector('.App-photo-container')
if(photosContainer.scrollTop + photosContainer.clientHeight === photosContainer.scrollHeight){ // 스크롤바를 맨 아래로 내렸을때
console.log('bottom of page !')
this.pageNum++ // 다음 페이지 조회하기
const photos = await this.getPhotos()
console.log([...this.state.photos, ...photos])
this.setState({ photos: [...this.state.photos, ...photos] }) // 기존 photos 배열에 현재 불러온 사진 리스트 추가하기
}
}
render() {
// console.log(process.env.REACT_APP_UNSPLASH_API_KEY)
const { photos, keyword } = this.state
return (
<div className='App'>
<form className='App-search-container'>
<input type="text" value={keyword} onChange={this.handleChange} placeholder="검색어 입력"/>
<button type="submit" onClick={this.searchPhotos}>
<i className="material-icons">
search
</i>
검색
</button>
</form>
<div className='App-photo-container' onScroll={this.handleScroll}>
{photos.length === 0 ?
<div>원하시는 사진을<br/> 검색창에서 찾아보세요 !</div>
: photos.map(photo => <img
key={photo.id}
className="App-photo-item"
src={photo.urls.small}
alt={photo.alt_description}/>)}
</div>
</div>
);
}
}
export default App;
* select ~ option 버튼 클릭시 열리도록 하기
import React, { Component } from 'react';
import './App.css'
import Card from './Card'
class App extends Component{
constructor(props){
super(props)
this.selectBox = React.createRef()
}
numOfCards = 3
handleOpen = (e) => {
console.log(this.selectBox.current)
this.selectBox.current.size = 3
}
render(){
return (
<div className='App'>
{new Array(this.numOfCards).fill(0).map((_, id) => {
return (
<Card id={id}>컨텐츠</Card>
)
})}
<button onClick={this.handleOpen}>열기</button>
<select name="choice" ref={this.selectBox}>
<option value="first">First Value</option>
<option value="second" selected>Second Value</option>
<option value="third">Third Value</option>
</select>
</div>
)
}
}
export default App
* 리스트에서 acitve 적용하기 - 하나의 컴포넌트만 active 적용하기
// variant : 초기 로딩시 적용할 주요 스타일
// sx : 동적으로 변경할 스타일
import React from 'react'
import './Card.css'
function Card({ children, handleHover, handleClick, variant, sx }){
return (
<div className={`Card-container ${variant}`} onClick={handleClick} onMouseEnter={handleHover} style={sx}>
<div className='Card-contents'>
{children}
</div>
</div>
)
}
export default Card
Card.defaultProps = {
children: "",
handleHover: () => console.log("마우스 호버"),
handleClick: () => console.log("마우스 클릭"),
}
Card.js 파일을 생성하고 위와 같이 작성하자!
// variant : 초기 로딩시 적용할 주요 스타일
// sx : 동적으로 변경할 스타일
function Card({ children, handleHover, handleClick, variant, sx }){
...중략
}
카드 컴포넌트는 기본적으로 컨텐츠, 호버 이벤트핸들러, 클릭 이벤트핸들러, variant, sx 를 props 로 전달받는다. 컨텐츠를 props 로 전달받는 이유는 이렇게 해야 카드 컴포넌트를 개발자 입맞에 맞게 커스터마이징할 수 있기 때문이다. variant 도 마찬가지로 웹 화면이 초기 로딩할때 적용할 카드 컴포넌트 스타일을 다양하게 설정하기 위함이다. sx 는 사용자 이벤트에 의하여 동적으로 변경할 스타일을 의미한다.
return (
<div className={`Card-container ${variant}`} onClick={handleClick} onMouseEnter={handleHover} style={sx}>
<div className='Card-contents'>
{children}
</div>
</div>
)
상위 컴포넌트로부터 전달받은 props 를 이용하여 웹 화면에 카드 컴포넌트를 렌더링한다.
Card.defaultProps = {
children: "",
handleHover: () => console.log("마우스 호버"),
handleClick: () => console.log("마우스 클릭"),
}
카드 컴포넌트로 전달되는 props 가 없을때 적용할 디폴트 값이다.
.Card-container{
cursor: pointer;
transition: .5s linear;
box-shadow: 0 .1rem .3rem lightgray;
}
.Card-contents{
height: 100%;
}
.Card-container.outlined{
border: 1px solid lightblue;
box-shadow: initial;
}
Card.css 파일을 위와 같이 작성하자! 카드 컴포넌트는 기본적으로 box-shadow 가 적용되어 있다. variant 에 outlined 를 설정하면 box-shadow 는 사라지고, border 를 적용한다.
import React, { Component } from 'react';
import './App.css'
import Card from './Card'
import logo from './logo.svg'
class App extends Component{
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectId: null, // 액티브 스타일을 적용할 아이템의 ID 값
}
numOfCards = 9
selectFlipCard = (id) => {
this.setState({ selectId: id })
}
render(){
const { sx, selectId } = this.state
return (
<div className='App'>
{new Array(this.numOfCards).fill(0).map((_, id) => {
const active = selectId !== null && selectId === id // 액티브 조건
const activeStyle = active ? { // 액티브 스타일
transform: "rotateY(180deg)", backgroundColor: "orange"
} : {}
return (
<Card key={id} variant="outlined" handleClick={() => this.selectFlipCard(id)}
sx={{...sx, ...activeStyle}}>
<img className="thumbnail" src={logo} alt={logo}/>
<div className='thumbnail-info'>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
)
})}
</div>
)
}
}
export default App
App 컴포넌트를 위와 같이 작성하자!
import Card from './Card'
import logo from './logo.svg'
리스트를 보여줄때 사용할 카드 컴포넌트와 카드 컴포넌트에 들어갈 이미지를 사용하기 위하여 임포트한다.
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectId: null, // 액티브 스타일을 적용할 아이템의 ID 값
}
sx 는 카드 컴포넌트의 디폴트 스타일을 적용하기 위한 값이고, selectId 는 리스트에서 액티브 스타일을 적용할 카드의 ID 값이다.
numOfCards = 9
웹 화면에 보여줄 카드 갯수이다.
selectFlipCard = (id) => {
this.setState({ selectId: id })
}
사용자가 특정 카드 컴포넌트를 클릭할때 실행할 이벤트핸들러 함수이다. 여기서는 사용자가 선택한 카드가 몇번째인지 기억하기 위하여 selectId 상태를 사용자가 클릭한 카드의 ID 값으로 설정한다.
new Array(this.numOfCards).fill(0).map((_, id) => {
...중략
}
numOfCards 수만큼 카드를 생성하고, 웹 화면에 보여준다. 언더바(_)는 콜백함수에서 딱히 사용하지 않는 값을 의미한다.
const active = selectId !== null && selectId === id // 액티브 조건
const activeStyle = active ? { // 액티브 스타일
transform: "rotateY(180deg)", backgroundColor: "orange"
} : {}
active 는 불리언(Boolean) 값이며, 사용자가 선택한 카드이면 true, 그렇지 않으면 false 이다. activeStyle 은 객체(Object)이며, 사용자가 선택한 카드이면 액티브 스타일을 적용하고, 그렇지 않으면 빈 객체({})로 설정한다.
<Card key={id} variant="outlined" handleClick={() => this.selectFlipCard(id)}
sx={{...sx, ...activeStyle}}>
<img className="thumbnail" src={logo} alt={logo}/>
<div className='thumbnail-info'>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
리스트 목록을 보여주기 위하여 Card 컴포넌트를 사용한다. 사용자가 클릭한 카드의 ID 값을 selectFlipCard 핸들러로 전달한다. Card 컴포넌트의 sx 속성은 초기에 정의한 디폴트 스타일과 액티브 스타일을 합쳐서 최종적인 스타일을 적용한다.
.App{
width: 50%;
margin: 100px auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 15px;
/* border: 1px solid red; */
}
.App .thumbnail{
width: 100%;
/* border: 1px solid red; */
}
.App .thumbnail-info{
padding: 1rem 2rem;
border-top: 1px solid lightgray;
}
App.css 파일을 위와 같이 작성하자!
아래와 같은 방식으로 하면 어떻게 될까?
import React, { Component } from 'react';
import './App.css'
import Card from './Card'
import logo from './logo.svg'
class App extends Component{
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectId: null, // 액티브 스타일을 적용할 아이템의 ID 값
}
numOfCards = 9
selectFlipCard = (id) => {
this.setState({ selectId: id })
}
render(){
const { sx, selectId } = this.state
return (
<div className='App'>
{new Array(this.numOfCards).fill(0).map((_, id) => {
const active = selectId !== null && selectId === id // 액티브 조건
// const activeStyle = active ? { // 액티브 스타일
// transform: "rotateY(180deg)", backgroundColor: "orange"
// } : {}
return (
<Card key={id} variant={`outlined ${active && "active"}`} handleClick={() => this.selectFlipCard(id)}
sx={{...sx}}>
<img className="thumbnail" src={logo} alt={logo}/>
<div className='thumbnail-info'>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
)
})}
</div>
)
}
}
export default App
App 컴포넌트를 위와 같이 수정하자!
// const activeStyle = active ? { // 액티브 스타일
// transform: "rotateY(180deg)", backgroundColor: "orange"
// } : {}
액티브 스타일을 자바스크립트가 아니라 아래와 같이 css 파일에서 정의한다.
/* 액티브 스타일 */
.active{
transform: rotateY(180deg);
background-color: orange;
}
sx 속성에 activeStyle 을 추가하는 것이 아니라 variant 속성에 "active" 클래스를 추가한다.
<Card key={id} variant={`outlined ${active && "active"}`} handleClick={() => this.selectFlipCard(id)}
sx={{...sx}}>
<img className="thumbnail" src={logo} alt={logo}/>
<div className='thumbnail-info'>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
하지만 특정 카드를 클릭할때 배경색이 오렌지색으로 변경되지 않는다. 이유는 sx 에도 배경색이 설정되어 있고, active 클래스에도 배경색이 설정되어 있는데 sx 는 style 속성에 지정한 스타일이라 우선순위가 더 높기 때문이다.
실험후 다시 다음 예제를 진행하기 위하여 코드를 원상복귀하도록 한다.
* 리스트에서 acitve 적용하기 - 여러개의 요소에 active 적용하기
import React, { Component } from 'react';
import './App.css'
import Card from './Card'
import logo from './logo.svg'
class App extends Component{
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectIds: [], // 액티브 스타일을 적용할 아이템의 ID 값 리스트
}
numOfCards = 9
selectFlipCard = (id) => {
const { selectIds } = this.state
if(!selectIds.includes(id)){ // 클릭한 카드가 현재 액티브가 아닌 경우
console.log("추가: ", selectIds)
this.setState({ selectIds: [...selectIds, id] })
}else{ // 클릭한 카드가 현재 액티브인 경우
console.log("삭제: ", selectIds)
this.setState({ selectIds: selectIds.filter(selectId => selectId !== id) })
}
}
render(){
const { sx, selectIds } = this.state
console.log(selectIds)
return (
<div className='App'>
{new Array(this.numOfCards).fill(0).map((_, id) => {
const active = selectIds.length > 0 && selectIds.includes(id) // 액티브 조건
const activeStyle = active ? { // 액티브 스타일
transform: "rotateY(180deg)",
backgroundColor: "orange"
} : {}
return (
<Card key={id} variant="outlined" handleClick={() => this.selectFlipCard(id)}
sx={{...sx, ...activeStyle}}>
<img className="thumbnail" src={logo} alt={logo}/>
<div className='thumbnail-info'>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
)
})}
</div>
)
}
}
export default App
App 컴포넌트를 위와 같이 수정하자!
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectIds: [], // 액티브 스타일을 적용할 아이템의 ID 값 리스트
}
기존에는 액티브를 적용할 컴포넌트가 하나였기 때문에 selectId 였지만, 현재는 여러개의 컴포넌트에 액티브를 적용해야 하므로 selectIds 라는 배열로 정의한다.
selectFlipCard = (id) => {
const { selectIds } = this.state
if(!selectIds.includes(id)){ // 클릭한 카드가 현재 액티브가 아닌 경우
console.log("추가: ", selectIds)
this.setState({ selectIds: [...selectIds, id] })
}else{ // 클릭한 카드가 현재 액티브인 경우
console.log("삭제: ", selectIds)
this.setState({ selectIds: selectIds.filter(selectId => selectId !== id) })
}
}
selectIds 배열에는 액티브 스타일을 적용할 카드들의 ID 만 담겨있는 배열이다. 그러므로 사용자가 클릭한 카드가 selectIds 배열에 존재하지 않으면 액티브한 카드가 아니므로 액티브 스타일을 적용하기 위하여 selectIds 배열에 추가한다. 반대로 사용자가 클릭한 카드가 selectIds 배열에 존재하면 이미 액티브한 카드이므로 액티브 스타일을 해제하기 위하여 selectIds 배열에서 삭제한다.
const active = selectIds.length > 0 && selectIds.includes(id) // 액티브 조건
액티브 조건이 위와 같이 변경되었다.
https://react-icons.github.io/react-icons/
* 컴포넌트 안의 여러개의 요소에 active 적용하기
npm install react-icons
리액트에서 아이콘을 사용하기 위하여 react-icons 라이브러리를 설치한다.
import React, { Component } from 'react';
import './App.css'
import Card from './Card'
import logo from './logo.svg'
import { FaHeart } from "react-icons/fa6";
class App extends Component{
state = {
sx: {backgroundColor: "#0e1111", color: "lightgray", borderRadius: "10px"}, // 카드 디폴트 스타일
selectIds: [], // 액티브 스타일을 적용할 아이템의 ID 값 리스트
}
numOfCards = 9
selectFlipCard = (id) => {
const { selectIds } = this.state
if(!selectIds.includes(id)){ // 클릭한 카드가 현재 액티브가 아닌 경우
console.log("추가: ", selectIds)
this.setState({ selectIds: [...selectIds, id] })
}else{ // 클릭한 카드가 현재 액티브인 경우
console.log("삭제: ", selectIds)
this.setState({ selectIds: selectIds.filter(selectId => selectId !== id) })
}
}
render(){
const { sx, selectIds } = this.state
console.log(selectIds)
return (
<div className='App'>
{new Array(this.numOfCards).fill(0).map((_, id) => {
const active = selectIds.length > 0 && selectIds.includes(id) // 액티브 조건
const activeStyle = active && "active" // 액티브 스타일
return (
<Card key={id} variant="outlined" handleClick={() => this.selectFlipCard(id)}
sx={{...sx}}>
<div className='thumbnail-img'>
<img className="thumbnail" src={logo} alt={logo}/>
<span className={`likes ${activeStyle}`}><FaHeart/></span>
</div>
<div className={`thumbnail-info ${activeStyle}`}>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
)
})}
</div>
)
}
}
export default App
App 컴포넌트를 위와 같이 수정한다.
import { FaHeart } from "react-icons/fa6";
좋아요 아이콘을 사용하기 위하여 폰트아우썸(fontawsome) 에서 아이콘을 임포트한다.
const activeStyle = active && "active" // 액티브 스타일
해당 카드가 액티브이면 카드 컴포넌트 안의 자식요소들에 "active" 클래스를 추가한다.
<Card key={id} variant="outlined" handleClick={() => this.selectFlipCard(id)}
sx={{...sx}}>
<div className='thumbnail-img'>
<img className="thumbnail" src={logo} alt={logo}/>
<span className={`likes ${activeStyle}`}><FaHeart/></span>
</div>
<div className={`thumbnail-info ${activeStyle}`}>
<h3 className="title">제목</h3>
<p className='description'>이미지 설명</p>
</div>
</Card>
카드 컴포넌트 자체에는 적용할 액티브 스타일이 없으므로 sx 속성은 수정한다. 썸네일 이미지를 div 요소로 감싸주고 클래스명을 "thumbnail-img"로 설정한다. 좋아요 아이콘의 위치를 조정하기 위하여 span 요소로 감싸주고 클래스명을 "likes" 로 설정하고, 해당 카드가 액티브 상태인 경우 "likes" 클래스에 "active" 클래스를 추가하여 액티브 스타일을 적용한다. 이에 더해, 해당 카드가 액티브 상태인 경우 "thumbnail-info" 에도 "active" 클래스를 추가하여 액티브 스타일을 적용한다.
.App{
width: 50%;
margin: 100px auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 15px;
/* border: 1px solid red; */
}
.App .thumbnail-img{
position: relative;
}
.App .thumbnail-img .likes{
position: absolute;
top: 10px; right: 10px;
transition: .3s linear;
}
.App .thumbnail-img .likes.active{
color: red;
}
.App .thumbnail{
width: 100%;
/* border: 1px solid red; */
}
.App .thumbnail-info{
padding: 1rem 2rem;
border-top: 1px solid lightgray;
transition: .3s linear;
}
.App .thumbnail-info.active{
color: orange;
}
App.css 파일을 위와 같이 수정한다.
.App .thumbnail-img{
position: relative;
}
.App .thumbnail-img .likes{
position: absolute;
top: 10px; right: 10px;
transition: .3s linear;
}
.App .thumbnail-img .likes.active{
color: red;
}
.App .thumbnail-info.active{
color: orange;
}
좋아요 버튼의 위치를 설정하기 위한 스타일과 좋아요 버튼이 액티브되었을때 적용할 스타일을 추가하였다. 또한, 썸네일 설명 부분이 액티브일때 적용할 스타일도 추가하였다.
* 연습과제 1
이미지 뷰어 코드를 활용하여 유튜브 영상 뷰어를 만들어보자!
const youtubeVideos = [
{
title: '[MV] IU(아이유) _ Celebrity',
src: 'https://www.youtube.com/embed/0-q1KafFCLU'
},
{
title: '[IU] "eight" Acoustic Ver. Live Clip',
src: 'https://www.youtube.com/embed/tJM0yIbg8iQ'
},
{
title: 'AKMU - 어떻게 이별까지 사랑하겠어, 널 사랑하는 거지',
src: 'https://www.youtube.com/embed/m3DZsBw5bnE'
},
{
title: 'LEE HI - "한숨 (BREATHE)" M/V',
src: 'https://www.youtube.com/embed/5iSlfF8TQ9k'
},
{
title: 'Bolbbalgan4 (볼빨간사춘기) - To My Youth (나의 사춘기에)',
src: 'https://www.youtube.com/embed/lHsg7X1gpRw'
}
]
export default youtubeVideos;
유튜브 영상을 검색해서 위와 같이 영상 제목과 src 경로를 복사해서 youtubeVideos.js 파일을 만들자!
* 연습과제 2
사용자 입력 처리하기 예제 코드에서 사용자가가 ID 와 PASSWORD 를 입력하고 Login 버튼을 클릭했을때 미리 저장된 사용자 정보와 일치하면 홈페이지를 보여주고, 그렇지 않으면 로그인 화면은 그대로 두고 alert 창을 띄워서 로그인에 실패했다는 메세지를 보여주도록 해보자!
const loginData = {
USER_ID: 'syleemomo',
USER_PASSWORD: 'sunrise@'
}
export default loginData
사용자 정보는 loginData.js 파일에 따로 저장하고 App 컴포넌트에서 임포트한다.
* 연습과제 3
이벤트 처리하기 연습과제 2에서 로그인 실패시 자바스크립트에서 기본으로 제공하는 alert 함수가 아니라 컴포넌트 스타일링 시간에 만든 Modal 컴포넌트를 띄워보자!
* 연습과제 4
파일 업로드 처리하기 예제 코드를 활용하여 사용자가 다수의 이미지를 업로드하였을때 해당 이미지들을 모두 보여주도록 해보자! 말하자면, 아미지 Preview 서비스이다.
<input className="Upload" type="file" onChange={this.handleChange} ref={this.fileInput} accept="image/*" multiple></input>
다수의 파일을 업로드하려면 input 요소의 속성에 multiple 을 추가하면 된다.
* 연습과제 5
사이드바 예제에서 사용자가 특정 메뉴를 클릭할때 사이드바가 화면에서 사라지도록 해보세요!
* 연습과제 6
사이드바 메뉴 이외의 영역을 클릭할때 사이드바가 화면에서 사라지도록 해보세요!
* 연습과제 7
화장품 사이트에 드롭다운 메뉴를 3개 정도 만들고, 마우스 호버일때 드롭다운 메뉴를 화면에 보여주세요!
* 연습과제 8
화장품 사이트에서 스크롤을 일정부분 내리면 네비게이션 바(헤더) 하단에 그림자 추가하기
* 연습과제 9
화장품 사이트에서 서버에서 데이터 조회가 완료된 경우(서버 응답이 완료된 경우) 알림창을 보여주기
* 연습과제 10
화장품 사이트에서 스크롤을 일정부분 내렸을때 페이지 우측 하단에 scroll to top 버튼을 보여주세요!
* 연습과제 11
unsplash API 를 사용한 예제에서 무한스크롤링을 하다가 마지막 페이지에 도달했을때 페이지 우측상단에 알림창을 띄워서 사용자에게 "더이상 조회할 사진이 없습니다!" 라는 문구로 알려주세요!
* 연습과제 12
unsplash API 로 조회한 사진목록에서 특정 사진 선택시 모달창을 브라우저 전체크기로 띄워서, 선택한 사진을 보여주세요!
* 연습과제 13
연습과제 12번에서 띄운 슬라이드에서 이전/다음 사진을 조회할 수 있도록 해보세요!
'프론트엔드 > React' 카테고리의 다른 글
리액트 기초이론 5 - 컴포넌트 스타일링 3 - Styled Components (0) | 2022.03.07 |
---|---|
리액트 기초이론 5 - 컴포넌트 스타일링 2 - SASS (0) | 2022.02.28 |
리액트 기초이론 9 - 리액트 훅(React Hook) (0) | 2021.10.22 |
리액트 기초이론 8 - 리액트 라우터 (0) | 2021.10.22 |
리액트 기초이론 6 - 요소 참조(ref) (0) | 2021.10.22 |