시작 전, 문제를 보고 기능 요구사항을 정리해보자
- 시맨틱 태그 사용
- 반응형
- 체크박스
- OS 반영
- [필수] 닫기 기능
- /api/cats/:id
- 반응형
- [추가] fade in/out
- [필수] 검색 결과가 없을 때 예외사항 UI
- [필수] 랜덤 버튼 추가(/api/cats/random50)
- [필수] 로딩 UI 추가
- input el 수정
- 최근 검색 키워드
- 새로고침 시 마지막 검색 결과 유지
- lazy load
- 스크롤 페이징
- 각 아이템 클릭 이벤트를 Event Delegation 기법 사용하여 수정
- [추가] 마우스 오버 시 고양이 이름
- /api/cats/random50
- 5개만 노출하며 좌, 우 슬라이드 버튼
- [선택] 트랜지션
- [필수] API status code에 따른 에러메시지 분리
- ES6 module
- API fetch 코드를 async/await 문으로 변경 및 에러 처리
코드 구조 관련
1. [필수] API의 status code에 따라 에러 메시지를 분리하여 작성해야 합니다.
const request = async (url) => {
try {
const res = await fetch(`${API_ENDPOINT}/api/cats/${url}`);
if (!res.ok) {
throw new Error(getErrorMsg(res.status));
}
const data = await res.json();
return data;
} catch (err) {
console.error("Error fetching data: ", err.message);
return { data: [], error: err.message };
}
};
const getErrorMsg = (status) => {
switch (status) {
case 400:
return "🚨 잘못된 요청입니다. 다시 확인해주세요.";
case 401:
return "🔒 인증이 필요합니다. 로그인 후 시도해주세요.";
case 403:
return "⛔ 접근이 거부되었습니다.";
case 404:
return "❌ 찾을 수 없는 데이터입니다.";
case 500:
return "🔥 서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
case 503:
return "⚠️ 현재 서버가 점검 중입니다. 잠시 후 다시 시도해주세요.";
default:
return `❗ 알 수 없는 오류 (Error Code: ${status})`;
}
};
2. ES6 module 형태로 코드를 변경합니다.
- type="module" 사용하여 ES6 모듈 사용을 명시
- 이후, 각 script를 import/export 문으로 관리한다.
<!-- index.html -->
<script type="module" src="src/main.js"></script>
// App.js
import SearchResult from "./SearchResult.js";
import Loading from "./Loading.js";
import api from "./api.js";
import ImageInfo from "./ImageInfo.js";
import Header from "./Header.js";
import RandomBanner from "./RandomBanner.js";
export default class App { ...
3. API fetch 코드를 async, await 문을 이용하여 수정해주세요. 해당 코드들은 에러가 났을 경우를 대비해서 적절히 처리가 되어있어야 합니다.
// api.js
const request = async (url) => {
try {
const res = await fetch(`${API_ENDPOINT}/api/cats/${url}`);
if (!res.ok) {
throw new Error(getErrorMsg(res.status));
}
const data = await res.json();
return data;
} catch (err) {
console.error("Error fetching data: ", err.message);
return { data: [], error: err.message };
}
};
HTML, CSS
1. 현재 HTML 코드가 전체적으로 <div>로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.
- header, section, article 태그 사용
// App.js
setupLayout() {
this.$header = document.createElement("header");
this.$target.appendChild(this.$header);
// 섹션 레이아웃을 잡아줌
this.$bannerWrapper = document.createElement("section");
this.$bannerWrapper.className = "BannerWrapper";
this.$target.appendChild(this.$bannerWrapper);
this.$searchResult = document.createElement("section");
this.$searchResult.className = "SearchResult";
this.$target.appendChild(this.$searchResult);
}
2. 유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row당 column 개수를 적절히 변경해주어야 합니다.
- 992px 이하: 3개
- 768px 이하: 2개
- 576px 이하: 1개
.SearchResult {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 10px;
}
다크모드
1. 다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다. 모든 글자 색상은 #FFFFFF, 배경 색상은 #000000로 한정합니다. 기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
- 다크모드 레이아웃 생성
- OS 다크모드 확인
- 체크박스 이벤트 리스너 등록
/* style.css */
.root {
--bg-color: white;
--text-color: black;
}
/* dark mode 처리 */
/* 아래 미디어 쿼리는 OS 다크모드를 감지하여 스타일 지정이 가능 */
@media (prefers-color-scheme: dark) {
:root {
--bg-color: black;
--text-color: white;
}
}
/* data-set 사용할 것이기에, data-theme가 dark일 때 다크모드 컬러 지정 */
[data-theme="dark"] {
--bg-color: black;
--text-color: white;
}
/* 라이트 모드 컬러 지정 */
[data-theme="light"] {
--bg-color: white;
--text-color: black;
}
/* 하위 모든 요소들에게도 적용 */
body,
body * {
background-color: var(--bg-color);
color: var(--text-color);
}
setTheme() {
// 이 로직은 생략가능: 사용자가 지정해둔 테마가 있었는지 확인
const userTheme = localStorage.getItem("theme");
// 2. OS 다크모드 확인
// theme: userTheme가 있다면 그 값(dark or light), 없다면 os값을 data-set theme에 지정해줌
const theme =
userTheme ||
// window.matchMedia로 OS 다크모드가 설정되어 있는지 확인
(window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light");
// window.matchMedia 값에 따라 dark 또는 light를 data-theme에 저장해준다.
// document.documentElement => html 태그에 data-theme 지정해줄 것
document.documentElement.setAttribute("data-theme", theme);
// 만약 다크모드면 체크처리
this.$darkModeCheckBox.checked = theme === "dark";
}
addEvent() {
// 3. 체크박스 이벤트리스너 추가
this.$darkModeCheckBox.addEventListener("change", () => {
const theme = this.$darkModeCheckBox.checked ? "dark" : "light";
// 다크모드 체크되면, 다크모드 활성화
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
});
}
이미지 모달
1. [필수] 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.
// ImageInfo.js
// 클릭 또는 ESC keydown 이벤트 시, visible을 false로 해서 모달을 닫는다.
// 클릭의 경우, 모달 밖 영역 클릭 시(contains ImageInfo) 또는 close 버튼 클릭 시 이벤트 동작
addEvent() {
this.$imageInfo.addEventListener("click", (e) => {
if (
e.target.classList.contains("ImageInfo") ||
e.target.classList.contains("close")
) {
this.setState({ visible: false });
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.setState({ visible: false });
}
});
}
2. 모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id를 통해 불러와야 합니다.
// ImageInfo.js
// 특정 이미지 클릭 시 다음이 실행되어 여기로 올 것
/* onClick: (image) => {
this.imageInfo.setState({
visible: true,
image,
});
},
*/
// 따라서 visible의 true일 때에만 api 호출해옴
// 렌더링할 데이터를 클릭한 고양이의 정보로 업데이트한 뒤, 렌더링
async setState(nextData) {
this.data = nextData;
if (this.data.visible) {
this.isLoading.setState(true);
try {
const catInfo = await api.fetchCatInfo(this.data.image.id);
if (catInfo.error) {
this.data.image.temperament = catInfo.error;
this.data.image.origin = catInfo.error;
throw new Error(catInfo.error);
}
if (catInfo) {
this.data.image.temperament = catInfo.data.temperament;
this.data.image.origin = catInfo.data.origin;
this.isLoading.setState(false);
}
} catch (err) {
this.isLoading.setState(false);
}
}
this.render();
}
3. 디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.
/* style.css */
.ImageInfo .content-wrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: #fff;
border: 1px solid #eee;
border-radius: 5px;
max-height: 100vh; /* 최대 높이를 화면의 80%로 설정 */
overflow-y: auto; /* 스크롤 가능하게 설정 */
/* 미디어쿼리 사용 문제 */
@media screen and (max-width: 768px) {
width: 100vw;
}
}
4. [추가] 모달 열고 닫기에 fade in/out을 적용해 주세요.
/* style.css */
/* fadeout 애니메이션을 위해 display: none을 할 때, setTimeout 0.3s를 주었다. */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.ImageInfo.show {
animation: fadeIn 0.3s ease forwards;
}
.ImageInfo.hide {
animation: fadeOut 0.3s ease forwards;
}
검색 페이지 관련
- [추가] 마우스 오버 시 고양이 이름
1. [필수] 검색 결과가 없는 경우, 유저가 불편함을 느끼지 않도록 UI적인 적절한 처리가 필요합니다.
// SearchResult.js
render() {
if (this.data) {
// 검색 결과 배열의 길이가 0이면 검색 결과가 없으므로 예외사항 처리 먼저 해준다.
if (this.data.length === 0) {
this.$target.innerHTML = `<div class="result-msg"><p>❌ 검색 결과가 없습니다.</p></div>`;
} else {
this.$target.innerHTML = this.data
.map(
(cat) => `
<article class="item">
<img class="lazy-img" title=${cat.name} data-src=${cat.url} alt=${cat.name} />
</article>
`
)
.join("");
}
}
2. [필수] SearchInput 옆에 버튼을 하나 배치하고, 이 버튼을 클릭할 시 /api/cats/random50을 호출하여 화면에 뿌리는 기능을 추가합니다. 버튼의 이름은 마음대로 정합니다.
// App.js
// 검색 결과를 state에 저장하고, 로컬스토리지에도 저장
setFetchedData(data) {
this.setState({ data });
localStorage.setItem("lastSearch", JSON.stringify(data));
}
setupHeader() {
// 헤더 안에 다크모드와 검색창을 넣어줄 것
this.$header = new Header({
$target: this.$header,
onSearch: (keyword) =>
this.fetchData(
api.fetchCats,
keyword,
(data) => this.setFetchedData(data),
(err) => (this.$searchResult.innerHTML = ERROR_MSG(err))
),
// 랜덤 버튼의 클릭 이벤트 리스너로 등록되어 있는 onRandom
onRandom: () =>
this.fetchData(
api.fetchRandomCats,
"",
(data) => this.setFetchedData(data),
(err) => (this.$searchResult.innerHTML = ERROR_MSG(err))
),
});
}
3. [필수] 데이터를 불러오는 중일 때, 현재 데이터를 불러오는 중임을 유저에게 알리는 UI를 추가해야 합니다.
export default class Loading {
constructor({ $target }) {
this.$target = $target;
this.render();
}
render() {
// 로딩 오버레이 생성
this.$loadingOverlay = document.createElement("div");
this.$loadingOverlay.classList.add("loading-overlay");
this.$loadingOverlay.style.display = "none";
// 로딩 스피너 컨테이너 (스피너 + 텍스트) 생성
this.$loadingContainer = document.createElement("div");
this.$loadingContainer.classList.add("loading-container");
// 로딩 스피너 생성
this.$loadingSpinner = document.createElement("div");
this.$loadingSpinner.classList.add("loading-spinner");
this.$loadingSpinner.style.display = "none";
// 스피너 안에 고양이 텍스트
this.$loadingText = document.createElement("span");
this.$loadingText.classList.add("loading-text");
this.$loadingText.innerText = "┙"; // 고양체에서 고양이 모양으로 사용
// 로딩 컨테이너에 스피너 & 텍스트 추가
this.$loadingContainer.appendChild(this.$loadingSpinner);
this.$loadingContainer.appendChild(this.$loadingText);
this.$loadingOverlay.appendChild(this.$loadingContainer);
this.$target.appendChild(this.$loadingOverlay);
}
// 로딩 스테이트에 따라 로딩 오버레이+스피너 활성화 또는 숨기기 해준다
setState(isLoading) {
if (isLoading) {
this.$loadingSpinner.style.display = "block";
this.$loadingOverlay.style.display = "flex"; // 오버레이 활성화
} else {
this.$loadingSpinner.style.display = "none";
this.$loadingOverlay.style.display = "none"; // 오버레이 숨기기
}
}
}
/* style.css */
/* 로딩 오버레이: 화면 전체 덮기 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
/* 스피너와 텍스트 포함하는 부모 컨테이너: relative */
.loading-container {
position: relative;
width: 80px;
height: 80px;
background-color: transparent;
}
/* 스피너: 스핀 애니메이션 적용 */
/* 보더탑을 투명화하면 로딩 스피너 형태가 된다. */
.loading-spinner {
width: 80px;
height: 80px;
border: 4px solid white;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
background-color: transparent;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 스피너 중앙의 텍스트: 스피너 안에 위치하기 위해 absolute */
.loading-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40px;
background-color: transparent;
color: white;
animation: bounce 0.8s infinite alternate ease-in-out;
}
/* 고양이 바운스 애니메이션: 기준 축(-50%, -50%)에서 위아래로 */
@keyframes bounce {
from {
transform: translate(-50%, -55%);
}
to {
transform: translate(-50%, -45%);
}
}
4. 페이지 진입 시 포커스가 input에 가도록 처리하고, 키워드를 입력한 상태에서 input을 클릭할 시에는 기존에 입력되어 있던 키워드가 삭제되도록 만들어야 합니다.
// SearchInput.js
render() {
const fragment = document.createDocumentFragment();
// 1. input el 생성
this.$searchInput = this.createInputEl();
fragment.appendChild(this.$searchInput);
...
// 페이지 진입 시 포커스가 input에 가도록 처리
this.$searchInput.focus();
}
createInputEl() {
const $input = document.createElement("input");
$input.className = "SearchInput";
$input.placeholder = "고양이를 검색해보세요.|";
return $input;
}
addEvent() {
// input 창 클릭 시, 입력 내용 초기화
this.$searchInput.addEventListener("click", (e) => {
e.target.value = "";
});
}
5. 최근 검색한 키워드를 SearchInput 아래에 표시되도록 만들고, 해당 영역에 표시된 특정 키워드를 누르면 그 키워드로 검색이 일어나도록 만듭니다. 단, 가장 최근에 검색한 5개의 키워드만 노출되도록 합니다.
export default class SearchHistory {
// Input의 onSearch 공유
constructor({ $target, onSearch }) {
this.$target = $target;
this.onSearch = onSearch;
// 로컬에 저장 기록이 있다면 가져와서 배열로 저장
this.keywordsHistory = localStorage.getItem("searchKeywords")
? localStorage.getItem("searchKeywords").split(",")
: [];
this.setLayout();
this.render();
}
setLayout() {
this.$searchKewordContainer = document.createElement("div");
this.$searchKewordContainer.className = "history-wrapper";
this.$target.appendChild(this.$searchKewordContainer);
}
// 로컬에 기록이 있다면 검색 키워드 노출면 검색 키워드 노출
render() {
if (this.keywordsHistory.length > 0) {
this.$searchKewordContainer.innerHTML = this.keywordsHistory
.map((keyword) => `<span class="searchHistory">${keyword}</span>`)
.join("");
}
this.addEvent();
}
// 검색 이벤트가 발생하면 실행되는 함수
addHistory(keyword) {
// 이미 저장된 키워드가 5개 이상이면, 맨 뒤 키워드를 없앤다.
if (this.keywordsHistory.length >= 5) {
this.keywordsHistory = this.keywordsHistory.slice(0, 4);
}
// 현재 키워드를 맨 앞에 저장(unshift)
this.keywordsHistory.unshift(keyword);
// 키워드 배열을 로컬에 저장하고 리렌더링
localStorage.setItem("searchKeywords", this.keywordsHistory);
this.render();
}
// 각 키워드 클릭 시, 그 키워드로 검색한다.
addEvent() {
document.querySelectorAll(".searchHistory").forEach(($keyword) => {
$keyword.addEventListener("click", (e) => {
this.onSearch(e.target.innerHTML);
});
});
}
}
6. 페이지를 새로고침해도 마지막 검색 결과 화면이 유지되도록 처리합니다.
// App.js
// 검색 이벤트 발생 시 실행되는 함수
setFetchedData(data) {
this.setState({ data });
// 마지막 검색 결과 로컬스토리지에 저장
localStorage.setItem("lastSearch", JSON.stringify(data));
}
// SearchResult.js
constructor({ $target, initialData, onClick }) {
this.$target = $target;
// 마지막 검색 결과가 있다면, 로컬스토리지에서 가져와서 파싱하여 이를 렌더링
this.data = JSON.parse(localStorage.getItem("lastSearch")) || initialData;
this.onClick = onClick;
this.render();
}
6. lazy load 개념을 이용하여, 이미지가 화면에 보여야 할 시점에 load 되도록 처리해야 합니다.
7. 스크롤 페이징 구현: 검색 결과 화면에서 유저가 브라우저 스크롤 바를 끝까지 이동시켰을 경우, 그 다음 페이지를 로딩하도록 만들어야 합니다.
8. [추가] 검색 결과 각 아이템에 마우스 오버시 고양이 이름을 노출합니다.
- 시간이 없을 시, 이전 포스팅의 getBoundingClientRect() 사용
// 스크롤 이벤트 추가
window.addEventListener("scroll", function () {
document.querySelectorAll(".lazy-img").forEach((img) => {
/* img.getBoundingClientRect().top: 현재 이미지의 화면에서의 위치
window.innerHeight: 현재 보이는 화면 높이*/
// data-src 속성이 현재 화면에 들어오면,
if (img.dataset.src && img.getBoundingClientRect().top < window.innerHeight) {
img.src = img.dataset.src; // 실제 이미지 로드
img.removeAttribute("data-src"); // 중복 실행 방지
}
});
});
- IntersectionObserver를 사용하는 방식
export default class SearchResult {
// 3. lazy load 동작 핵심 로직
handleLazyLoad = (entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 뷰포트에 들어오면
const img = entry.target;
img.src = img.dataset.src; // data-src 속성에 저장되어 있는 실제 이미지 URL 가져와서 적용
observer.unobserve(img); // 로딩 후 감지 중지
}
});
};
observeImgs() {
// 2. lazy load를 위한 Intersection Observer
this.observer = new IntersectionObserver(this.handleLazyLoad, {
root: null, // viewport 기준으로 감지하겠다다
rootMargin: "0px", // 뷰포트 경계에서 추가 여백 없이 바로 감지
threshold: 0.1, // 이미지가 10% 이상 보일 때 로드
});
// 모든 .lazy-img 요소를 Intersection Observer로 감지
// 뷰포트에 나타나면 handleLazyLoad 실행 → 실제 이미지 로딩
/* observe(img) → 해당 요소를 감시 시작
unobserve(img) → 감시 대상에서 제외 */
this.$target.querySelectorAll(".lazy-img").forEach((img) => {
this.observer.observe(img);
});
}
setState(nextData) {
this.data = nextData.data;
this.render();
}
// 1. src를 data-src 속성으로 변경
render() {
if (this.data) {
if (this.data.length === 0) {
this.$target.innerHTML = `<div class="result-msg"><p>❌ 검색 결과가 없습니다.</p></div>`;
} else {
this.$target.innerHTML = this.data
// title 속성으로 마우스 오버 시, 고양이 이름 노출
.map(
(cat) => `
<article class="item">
<img class="lazy-img" title=${cat.name} data-src=${cat.url} alt=${cat.name} />
</article>
`
)
.join("");
}
}
// lazy load 적용
this.observeImgs();
this.addEvent();
}
}
9. SearchResult에 각 아이템을 클릭하는 이벤트를 Event Delegation 기법을 이용해 수정해주세요.
addEvent() {
// Event Delegation 기법 사용하기
// 1. 부모 요소에 클릭 이벤트 등록
this.$target.addEventListener("click", (e) => {
// 2. 클릭한 지점에서 가장 가까운 item 찾기
const $item = e.target.closest(".item");
if (!$item) return; // item 없으면 return
// 3. querySelectorAll 반환값은 유사배열
// => Array.from() 사용하여 배열로 만들어줌
// => 모든 item 중에 선택된 $item el의 index 가져옴
const index = Array.from(this.$target.querySelectorAll(".item")).indexOf(
$item
);
// 해당 index의 데이터를 가져와서 onClick 함수 실행
if (index !== -1) {
this.onClick(this.data[index]);
}
});
}
랜덤 고양이 배너 섹션 추가
1. 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.
2. 앱이 구동될 때 /api/cats/random50 api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.
3. 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.
4. 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보여줍니다.(트렌지션은 선택)
// App.js
// 앱에서 random50 api 호출 결과를 banner에 전달
async setupBanner() {
this.fetchData(
api.fetchRandomCats,
"",
(data) => {
this.bannerData = data;
this.banner = new RandomBanner({
$target: this.$bannerWrapper,
data: this.bannerData,
});
},
(error) => {
this.$bannerWrapper.innerHTML = ERROR_MSG(
"배너 이미지 요청 중 오류가 발생하였습니다."
);
}
);
this.setSearchResult();
}
export default class RandomBanner {
constructor({ $target, data }) {
this.$target = $target;
this.data = data;
this.currentIndex = 0;
this.totalPages = 5;
this.render();
this.updateButtonState();
this.addEvent();
}
render() {
// 배너 전체 감싸는 컨테이너
this.$bannerContainer = document.createElement("div");
this.$bannerContainer.className = "banner-container";
// 배너 아이템들이 들어갈 내부 div
this.$bannerInner = document.createElement("div");
this.$bannerInner.className = "banner-inner";
this.$prevBtn = document.createElement("button");
this.$prevBtn.innerHTML = "<";
this.$nextBtn = document.createElement("button");
this.$nextBtn.innerHTML = ">";
this.$target.appendChild(this.$prevBtn);
this.$bannerContainer.appendChild(this.$bannerInner);
this.$target.appendChild(this.$bannerContainer);
this.$target.appendChild(this.$nextBtn);
}
// 1. 데이터 중, 5개만 잘라서 배너에 렌더링
setBannerImg() {
if (this.data) {
this.$bannerInner.innerHTML = this.data
.slice(0, 5)
.map(
(cat) => `
<div class="banner-item">
<img class="lazy-img" title="${cat.name}" src="${cat.url}" alt="${cat.name}" />
</div>
`
)
.join("");
}
// 2. 배너 전체의 가로 길이를 페이지 개수에 맞게 조정
// 5개를 배치할 것이기 때문에 500%로 지정된다.
this.$bannerInner.style.width = `${this.totalPages * 100}%`;
}
// 1개 씩 이동하도록 translateX 설정
// ex. 5개중 1개를 오른쪽으로 이동 시, x축 -20% 이동
updateBannerPosition() {
this.$bannerInner.style.transform = `translateX(-${
(this.currentIndex * 100) / this.totalPages
}%)`;
}
addEvent() {
// 마지막 페이지가 아닐 때, 다음 버튼 클릭 시 index+1 후 translateX 설정
this.$nextBtn.addEventListener("click", () => {
if (this.currentIndex < this.totalPages - 1) {
this.currentIndex += 1;
this.updateBannerPosition();
}
// 버튼 상태 업데이트
this.updateButtonState();
});
// 첫 페이지가 아닐 때, 이전 버튼 클릭 시 index-1 후 translateX 설정
this.$prevBtn.addEventListener("click", () => {
if (this.currentIndex > 0) {
this.currentIndex -= 1;
this.updateBannerPosition();
}
this.updateButtonState();
});
this.updateButtonState();
}
// 첫 페이지면 이전 버튼 비활성, 마지막 페이지면 다음 버튼 비활성
updateButtonState() {
this.$prevBtn.disabled = this.currentIndex === 0;
this.$nextBtn.disabled = this.currentIndex === this.totalPages - 1;
}
}
/* style.css */
.BannerWrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin: 1em auto;
width: 80%;
min-width: 500px;
min-height: 380px;
}
.BannerWrapper button {
border: none;
font-size: 3rem;
cursor: pointer;
margin: 0 1rem;
}
.BannerWrapper button:disabled {
color: #aaa;
cursor: not-allowed;
}
.banner-container {
width: 100%;
overflow: hidden;
}
.banner-inner {
display: flex;
transition: transform 0.5s ease-in-out;
}
.banner-item {
/* 부모 요소의 20%를 차지, 1개의 아이템만 보이도록 */
flex: 20%;
}
.banner-item img {
width: 100%;
height: 100%;
object-fit: cover;
max-height: 380px;
}
'[Study] 개발 공부' 카테고리의 다른 글
[Study] '--save-dev' 왜, 언제 쓰는지 아시나요? (0) | 2025.03.25 |
---|---|
[알고리즘] 공부(reduce, parseInt 대체) (0) | 2025.03.20 |
[Vanilla-JS] 고양이 사진 검색 사이트😺 (0) | 2025.03.10 |
자주 사용하는 HTTP 상태 코드 별 에러 메시지 처리하기! (0) | 2025.03.04 |
Vercel로 프론트엔드 프로젝트 배포해보기 (0) | 2024.06.28 |