소스코드 참고 시 출처 표기 부탁드립니다!
참고 요구사항의 순서는 난이도와 상관이 없음
<div>
로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다./*
device-width is deprecated
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/device-width
*/
@media only screen and (max-width: 992px) {
.SearchResult {
grid-template-columns: repeat(3, 1fr);
}
}
@media only screen and (max-width: 768px) {
.SearchResult {
grid-template-columns: repeat(2, 1fr);
}
}
@media only screen and (max-width: 576px) {
.SearchResult {
display: block;
}
}
/* css OS dark mode */
@media (prefers-color-scheme: dark) {
body {
--bg: #000;
--font: #fff;
background-color: var(--bg);
color: var(--font);
}
}
/*
js OS dark mode
버튼 토글 전 => css에서 prefers-color-scheme로 스타일링
버튼 토글 시 => body의 data-theme 속성 토글링 => css에서 body[data-theme='dark']{} 셀렉터로 스타일링
*/
// DarkModeToggler.js
$darkModeToggler.onclick = () => {
let originTheme = document.body.dataset.theme;
if (!originTheme) {
originTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
let toggledTheme = originTheme === 'dark' ? 'light' : 'dark';
document.body.setAttribute('data-theme', toggledTheme);
};
필수
이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.// Imageinfo.js (모달 UI로 변경 예정)
// tabindex 속성
$el.setAttribute('tabindex', 0);
onClick(e) {
const clickedClassName = e.target.className;
if (
clickedClassName === 'close' || // 닫기 버튼
clickedClassName.indexOf('ImageInfo') !== -1 // 백드랍 클릭
) {
this.removeWithFadeOut();
}
}
onKeyDown(e) {
e.key === 'Escape' && this.removeWithFadeOut(); // ESC 키보드 입력 시
}
// 렌더링 시 focus하기
render(){
/.../
this.$el.focus();
}
this.$el.addEventListener('click', this.onClick);
this.$el.addEventListener('keydown', this.onKeyDown);
// SearchResult.js
SearchResult 클릭 시 => target이 고양이카드일 경우 => dataset-id를 이용해 api fetch => fetched data는 dataset을 이용해 캐싱 => data를 이용해 Imageinfo 렌더링
constructor(){
this.onClick = this.onClick.bind(this);
}
async getCatInfo($catCard) {
if ($catCard.dataset.catInfo) { // 캐싱된 데이터가 있을 경우
return JSON.parse($catCard.dataset.catInfo);
}
try {
const { data } = await api.fetchCatById($catCard.dataset.id);
if (!Object.keys(data).length) { // api 요청 성공, but 데이터가 없을 때
throw new Error(errorMessage);
}
$catCard.dataset.catInfo = JSON.stringify(data); // 캐싱
const { id, url, name, temperament, origin } = data;
return { id, url, name, temperament, origin };
} catch (e) {
console.error(e);
throw new Error(
e.message === errorMessage // api 요청은 성공했으나 data가 없을 경우
? e.message
: '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.' //api 요청에 실패할 경우
);
}
}
async onClick(e) {
if (this.isLoading) return; // 로딩 중일 경우 클릭 이벤트 방지
const $catCard = e.target.closest('.item');
if (!$catCard) {
return;
}
const loading = new Loading();
this.isLoading = true;
try {
const catInfo = await this.getCatInfo($catCard);
new ImageInfo(document.body, catInfo).render();
} catch (e) {
new ErrorMessage($catCard, e.message);
} finally {
loading.$el.remove();
this.isLoading = false;
}
}
this.$el.addEventListener('click', this.onClick);
// 렌더링 시
this.$el.add('fade-in');
// 언마운팅 시
this.$el.remove('fade-in');
this.$el.add('fade-out');
input
에 가도록 처리하고, 키워드를 입력한 상태에서 input
을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.// App.js
앱 렌더링 후, dom traverse해서 input을 찾은 후 focus하기
render() {
this.children.forEach((child) => child.render && child.render());
this.$target.querySelector('.SearchInput').focus();
}
// SearchInput.js
onClick = (e) => {
this.$el.value = '';
};
필수
데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.// Loading.js
export default class Loading {
constructor() {
this.$el = document.createElement('div');
this.$el.className = 'loading';
this.$el.innerHTML = `
<span class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</span>
`;
document.body.append(this.$el);
}
}
// usage
// 렌더링
const loading = new Loading()
// 언마운팅
loading.$el.remove()
// https://codepen.io/changhyun2/pen/GRjEYzO
.loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
}
.loading-dot {
display: inline-block;
background: linear-gradient(-90deg, #666, #999, #bbb, #fff);
background-size: 600% 600%;
animation: gradient 1s infinite, flick 1s infinite;
line-height: 1;
vertical-align: middle;
width: 15px;
height: 15px;
border-radius: 100%;
background-color: #000;
}
.loading-dot:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dot:last-child {
animation-delay: 0.4s;
}
.loading-dot:not(:last-child) {
margin-right: 3px;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
10% {
background-position: 0% 50%;
}
}
@keyframes flick {
0%,
80%,
100% {
opacity: 0;
}
30%,
50% {
opacity: 1;
}
}
필수
검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.불러올 수 있는 cat keyword 리스트를 렌더링하면 좋을 듯
일단 에러메세지로 대체
// SearchInput.js
try {
const res = await api.fetchCats(searchedKeyword);
} catch (e) {
new ErrorMessage({
$target: this.$el.closest('.Search'),
message:
e.message === errorMessage
? e.message
: '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.',
});
}
// ErrorMessage.js
const DURATION = 1500;
export default class ErorrMessage {
constructor($target = document.body, message) {
const { bottom, left } = $target.getBoundingClientRect();
const $el = document.createElement('div');
$el.className = 'error-message';
$el.textContent = message;
$el.style.position = 'fixed';
$el.style.left = left + 'px';
$el.style.top = `${bottom + 50}px`;
$el.style.zIndex = 1001;
this.$el = $el;
$target.insertAdjacentElement('afterend', this.$el);
this.$el.classList.add('fade-in');
setTimeout(() => {
this.$el.classList.remove('fade-in');
this.$el.classList.add('fade-out');
this.$el.ontransitionend = () => this.$el.remove();
}, DURATION);
}
}
SearchInput
아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다./*
store.js 임시 구현
context를 create하는 컴포넌트 => constructor에서 store.set('context', initialData)하기
context의 update에 따라 리렌더링되는 컴포넌트 => constructor에서 store.subscribe('context', this)하기
context를 set하는 코드 라인 => data를 업데이트하는 곳에서 store.set('context', data)하기
context를 get하는 코드 라인 => data를 사용하는 곳에서 store.get('context')하기
*/
const store = {
subscribe: (context, ref) => {
store[context].refs.push(ref);
},
get: (context) => {
return store[context].data;
},
set: (context, data) => {
const initialSet = !store[context];
if (initialSet) {
store[context] = {
data: data,
refs: [],
};
return;
}
console.log('new data', data);
store[context].data = data;
store[context].refs.forEach((ref) => ref.render());
},
};
export default store;
// SearchHistory.js
import store from '../../store.js';
export default class SearchHistory {
constructor($target) {
this.$target = $target;
this.$el = document.createElement('ul');
this.$el.className = 'SearchHistory';
this.$target.append(this.$el);
store.set('search-history', []); // create 'search-history' context, initialData is []
store.subscribe('search-history', this); // subscribe 'search-history' context
}
render() { // 'search-history'가 다른 곳에서 업데이트될 경우 render()가 실행됨
this.$el.innerHTML = store
.get('search-history') // get 'search-history' context
.map((searched) => `<li>${searched}</li>`)
.join('');
}
}
// localStorage.js
const localStorage = {
get: (key) => JSON.parse(window.localStorage.getItem(key)),
set: (key, value) => window.localStorage.setItem(key, JSON.stringify(value)),
};
// SearchInput.js
async updateSearchResult(searchedKeyword) {
/../
store.set('search-result', res.data); // store set과 함께
localStorage.set('cats-search-result', res.data); // localStorage에 최근 'search-result' data 저장해두기
/../
}
// SearchResult.js
constructor($parent) {
/../
// localStorage에서 initialData 가져오기
store.set('search-result', localStorage.get('cats-search-result') || []);
store.subscribe('search-result', this);
/../
}
필수
SearchInput
옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50 을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.// RandomSearchButton.js
// SearchInput에서의 데이터 처리 과정과 유사
async updateSearchResult() {
const loading = new Loading();
try {
const { data } = await api.fetchRandomCats();
store.set('search-result', data);
localStorage.set('cats-search-result', data);
} catch (e) {
console.warn(e);
new ErrorMessage({
$target: this.$el,
message: '서버가 원활하지 않습니다. 잠시 후 다시 시도해주세요.',
});
} finally {
loading.$el.remove();
this.$el.disabled = false;
}
}
async onClick() {
this.$el.disabled = true;
this.updateSearchResult();
}
this.$el.addEventListener('click', this.onClick);
/*
lazyload.js
구현 방법이 다양함
1. html loading 속성
2. intersectionObserver
3. scroll event
img width 100%를 지원하는 intersectionObserver 선택
root : lazyload를 적용할 뷰포트 지정 => document로 지정할 경우 => 웹 브라우저 화면
rootMargin : 뷰포트 아래로의 마진값 => 이 위치까지 observer callback 실행
threshold : 뷰포트에서 entry가 보여지는 비율(threshold)에 따라 observer callback이 실행됨 => 1.0일 경우 뷰포트에서 entry 돔이 모두 보여져야 callback이 실행됨
*/
export default function lazyLoad(options = {}) {
const { root = document, rootMargin = '500px', threshold = 0 } = options;
var lazyImageWrappers = [].slice.call(
root.querySelectorAll('.img-wrapper.lazy')
);
if ('IntersectionObserver' in window) {
const observerCallback = (entries) =>
entries.forEach((entry) => {
if (entry.isIntersecting) {
// entry가 뷰포트(root)에 보여질 때(isIntersecting),
// 이미지 src를 data-src에서 가져오고
// 이미지가 load될 때 placeholder 제거
const lazyImage = entry.target.querySelector('img');
const placeholder = entry.target.querySelector('.img-placeholder');
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove('lazy'); // lazy 클래스 제거
lazyImage.onload = () => {
placeholder.classList.add('fade-out');
placeholder.ontransitionend = () => placeholder.remove();
};
lazyImageObserver.unobserve(entry.target); // lazy load를 마친 후 entry unobserve하기
}
})
let lazyImageObserver = new IntersectionObserver(
observerCallback,
{
root,
rootMargin,
threshold,
}
);
lazyImageWrappers.forEach(function (lazyImageWrapper) {
lazyImageObserver.observe(lazyImageWrapper);
});
} else {
// 미지원일 경우 scrollEvent로 레이지로드 적용
// Possibly fall back to event handlers here
}
}
추가
검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.// SearchResult.js
/*
https://javascript.info/mousemove-mouseover-mouseout-mouseenter-mouseleave#:~:text=The%20mouseover%20event%20occurs%20when,and%20the%20other%20one%20%E2%80%93%20relatedTarget%20.
mouseover/out => event bubbling 발생
mouseleave/enter => event bubbling 발생 x
*/
onMouseOver(e) {
const item = e.target.closest('.item'); // 고양이 카드
if (!item) return;
// 페이지 로드 시, 카드 위치에 마우스가 올라가있을 경우 중복되어 item-name이 렌더링되는 현상 방지
if (item.querySelector('.item-name')) return;
const name = item.dataset.name;
const $itemName = document.createElement('span');
$itemName.textContent = name;
$itemName.className = 'item-name';
item.querySelector('.img-wrapper').append($itemName);
}
onMouseOut(e) {
const item = e.target.closest('.item');
if (!item) return;
item.querySelector('.item-name').remove();
}
필수
API 의 status code 에 따라 에러 메시지를 분리하여 작성해야 합니다.const API_ENDPOINT =
'https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev';
const statusErrorFromResponse = (res) => {
if (res.status < 300) return false;
if (res.status < 400) {
return `Redirects Error with status code ${res.status}`;
}
if (res.status < 500) {
return `Client Error with status code ${res.status}`;
}
if (res.status < 600) {
return `Server Error with status code ${res.status}`;
}
};
const api = {
fetchCats: async (keyword) => {
const res = await fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`);
const statusErrorMessage = statusErrorFromResponse(res);
if (statusErrorMessage) throw new Error(statusErrorMessage);
return res.json();
},
fetchCatById: async (id) => {
const res = await fetch(`${API_ENDPOINT}/api/cats/${id}`);
const statusErrorMessage = statusErrorFromResponse(res);
if (statusErrorMessage) throw new Error(statusErrorMessage);
return res.json();
},
fetchRandomCats: async () => {
const res = await fetch(`${API_ENDPOINT}/api/cats/random50`);
const statusErrorMessage = statusErrorFromResponse(res);
if (statusErrorMessage) throw new Error(statusErrorMessage);
return res.json();
},
};
export default api;
/api/cats/random50
api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.