현재 HTML 코드가 전체적으로
<div>
로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.
header, nav, section, article, footer
유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 적절히 변경해주어야 합니다.
- 992px 이하: 3개, 768px 이하: 2개, 576px 이하: 1개
.SearchResult {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(4, minmax(250px, 1fr));
grid-gap: 10px;
}
/* 992px 이하 3개*/
@media (max-width : 992px){
.SearchResult{
grid-template-columns: repeat(3, minmax(250px, 1fr));
}
}
/* 768px 이하 적용 2개 */
@media (max-width : 768px){
.SearchResult{
grid-template-columns: repeat(2, minmax(250px, 1fr));
}
}
/* 576px 이하 적용 1개 */
@media (max-width : 576px){
.SearchResult{
grid-template-columns: repeat(1, minmax(250px, 1fr));
}
}
다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다.
CSS 파일 내의 다크 모드 관련 주석을 제거한 뒤 구현합니다.
모든 글자 색상은 #FFFFFF , 배경 색상은 #000000 로 한정합니다.
기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
CSS 미디어 쿼리 prefers-color-scheme (다크 모드)
웹페이지 다크 모드 지원하기
import SearchInput from "./SearchInput.js";
import SearchResult from "./SearchResult.js";
import ImageInfo from "./ImageInfo.js";
import DarkmodeToggle from "./DarkmodeToggle.js";
import api from "./api.js";
console.log("app is running!");
export default class App {
$target = null;
data = [];
constructor($target) {
this.$target = $target;
this.darkmodeToggle = new DarkmodeToggle($target)
//...
export default class DarkmodeToggle {
constructor($target) {
const darkModeWrapper = document.createElement("div");
darkModeWrapper.className = "dark-mode-wrapper";
this.darkModeWrapper = darkModeWrapper;
this.currentMode = localStorage.getItem('theme')
|| (
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
)
document.documentElement.setAttribute('data-theme', this.currentMode);
$target.appendChild(darkModeWrapper);
this.render();
}
toggleMode() {
this.currentMode = this.currentMode === "dark" ? "light" : "dark"
const darkModeBtn = document.querySelector('.dark-mode-btn')
darkModeBtn.innerText = this.currentMode == "dark" ? "🌕" : "🌑"
document.documentElement.setAttribute('data-theme', this.currentMode);
localStorage.setItem('theme', this.currentMode);
}
render() {
const darkModeBtn = document.createElement("span");
darkModeBtn.className = "dark-mode-btn";
darkModeBtn.innerText = this.currentMode == "dark" ? "🌕" : "🌑"
darkModeBtn.addEventListener("click", this.toggleMode)
this.darkModeWrapper.appendChild(darkModeBtn);
}
}
:root {
--background: #fff;
--text: #000;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #000;
--text: #fff;
}
}
[data-theme="light"] {
--background: #fff;
--text: #000;
}
[data-theme="dark"] {
--background: #000;
--text: #fff;
}
body {
background-color: var(--background);
color: var(--text);
transition: background 500ms ease-in-out, color 200ms ease;
}
.dark-mode-btn {
font-size: 30px;
}
디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.
@media (max-width : 576px){
.SearchResult{
grid-template-columns: repeat(1, minmax(250px, 1fr));
}
.ImageInfo .content-wrapper {
width: 100vw;
}
}
필수
이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.
export default class ImageInfo {
$imageInfo = null;
data = null;
constructor({ $target, data }) {
const $imageInfo = document.createElement("div");
$imageInfo.className = "ImageInfo";
this.$imageInfo = $imageInfo;
$target.appendChild($imageInfo);
document.addEventListener("keydown", (e) => { // ESC 버튼 클릭 시 닫기
e.key === "Escape" ? this.closeImageInfo() : ''
});
this.data = data;
this.render();
}
setState(nextData) {
this.data = nextData;
this.render();
}
closeImageInfo() {
this.data.visible = false
this.$imageInfo.style.display = "none";
const $imageInfo = document.querySelector('.ImageInfo')
$imageInfo.innerHTML = null
}
render() {
if (this.data.visible) {
const { name, url, temperament, origin } = this.data.image;
const contentWrapper = document.createElement('div')
contentWrapper.className = 'content-wrapper'
const title = document.createElement('div')
title.className = 'title'
const titleText = document.createElement('span')
titleText.innerText = name
const closeButton = document.createElement('div')
closeButton.className = 'close'
closeButton.innerText = '❌'
const image = document.createElement('img')
image.setAttribute('src', url)
image.setAttribute('alt', name)
const description = document.createElement('div')
const temperamentText = document.createElement('div')
temperamentText.innerText = temperament
const originText = document.createElement('div')
originText.innerText = origin
title.append(titleText, closeButton)
description.append(temperamentText, originText)
contentWrapper.append(title, image, description)
this.$imageInfo.append(contentWrapper)
this.$imageInfo.addEventListener('click', (event) => {
if (event.target.className === 'ImageInfo'
|| event.target.className === 'close') {
this.closeImageInfo()
}
})
this.$imageInfo.style.display = "block";
} else {
this.$imageInfo.style.display = "none";
}
}
}
모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id 를 통해 불러와야 합니다.
import 'regenerator-runtime/runtime'
const API_ENDPOINT =
"https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev";
// const api = {
// fetchCats: keyword => {
// return fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`).then(res =>
// res.json()
// );
// },
// };
const request = async (url) => {
try {
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
return data;
} else {
const errData = await res.json();
throw errData;
}
} catch (e) {
throw {
message: e.message,
status: e.status,
};
}
};
const api = {
fetchCats: async (keyword) => {
try {
const result = await request(
`${API_ENDPOINT}/api/cats/search?q=${keyword}`
);
return result
} catch (e) {
return e;
}
},
fetchDetailInfo: async (id) => {
try {
const result = await request(`${API_ENDPOINT}/api/cats/${id}`);
return result
} catch (e) {
return e;
}
},
}
export default api;
import api from "./api.js";
(...)
async render() {
if (this.data.visible) {
const { name, url, temperament, origin } = this.data.image;
const detailInfo = await api.fetchDetailInfo(this.data.image.id);
console.log('here', detailInfo);
const contentWrapper = document.createElement('div')
contentWrapper.className = 'content-wrapper'
const title = document.createElement('div')
title.className = 'title'
const titleText = document.createElement('span')
titleText.innerText = name
const closeButton = document.createElement('div')
closeButton.className = 'close'
closeButton.innerText = '❌'
const image = document.createElement('img')
image.setAttribute('src', url)
image.setAttribute('alt', name)
const description = document.createElement('div')
const temperamentText = document.createElement('div')
temperamentText.innerText = `성격 : ${detailInfo.data.temperament}`
const originText = document.createElement('div')
originText.innerText = `태생 : ${detailInfo.data.origin}`
(...)
추가
모달 열고 닫기에 fade in/out을 적용해 주세요.
(...)
closeImageInfo() {
this.$imageInfo.removeAttribute('data-status')
this.$imageInfo.setAttribute('data-status', 'hidden');
setTimeout(() => {
this.$imageInfo.removeAttribute('data-status');
this.$imageInfo.setAttribute('data-status', 'removed');
this.$imageInfo.innerHTML = null
this.data.visible = false
this.$imageInfo.style.display = "none";
}, 1000);
}
async render() {
console.log('render');
if (this.data.visible) {
this.$imageInfo.setAttribute('data-status', 'shown')
(...)
.ImageInfo {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
}
/* REF: https://stackoverflow.com/questions/19466670/trigger-css-animation-fade-in-fade-out-with-javascript-only-works-in-one-dir */
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; display: block; }
}
.ImageInfo[data-status="shown"] {
opacity: 1;
animation: fade 1s;
}
.ImageInfo[data-status="hidden"] {
opacity: 0;
animation: fade 2s;
animation-direction: reverse;
}
.ImageInfo[data-status="removed"] {
display: none;
}
Photo by Hannah Troupe on Unsplash