feat(home): Featured 모바일 터치 스크롤·화살표 끝 비활성
- 트랙에 touch-pan-x·webkit 가로 스크롤·overscroll-x-contain 적용 - scroll·ResizeObserver로 이전/다음 disabled 동기화 - v0.0.60 문서 반영 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.60
|
||||
|
||||
### 홈 Featured 모바일 스크롤·화살표 상태
|
||||
|
||||
가로 오버플로 트랙은 기본적으로 스크롤 가능하지만, 카드 전체가 링크일 때 브라우저가 세로 제스처에 가깝게 해석하거나 체인 스크롤이 나는 경우가 있어 `touch-pan-x`와 `overscroll-x-contain`으로 가로 우선·부모 스크롤 전파를 줄였다. 화살표는 스크롤 한계에서 의미 없는 클릭을 막기 위해 `scrollLeft`와 `scrollWidth - clientWidth` 비교로 `disabled`를 두고, 레이아웃 변화에 맞추기 위해 `ResizeObserver`를 함께 썼다.
|
||||
|
||||
## 2026-05-11 v0.0.59
|
||||
|
||||
### Nuxt `#internal/nuxt/paths` Node 해석 오류
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||
|
||||
### Post 페이지
|
||||
|
||||
- Main 좌우 패딩: 24px → 20px
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.60
|
||||
|
||||
- 홈 Featured 가로 트랙에 `touch-pan-x`·`-webkit-overflow-scrolling:touch`·`overscroll-x-contain`을 두어 모바일에서 손가락으로 가로 슬라이드(스크롤·스냅)가 잘 먹도록 함.
|
||||
- Featured 이전/다음 화살표는 스크롤 시작·끝에 따라 `disabled`와 시각적 비활성 처리, `ResizeObserver`·`scroll`로 상태 동기화.
|
||||
|
||||
## v0.0.59
|
||||
|
||||
- Nuxt 3.21 SSR이 `#internal/nuxt/paths`를 외부 import로 두는데 `.nuxt/paths.mjs`가 기본적으로 디스크에 쓰이지 않아 Node가 루트 `package.json`에서 해석하지 못하던 오류 수정.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.59",
|
||||
"version": "0.0.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -88,6 +88,67 @@ const featuredPosts = computed(() => posts.value.slice(0, 6))
|
||||
const latestPosts = computed(() => posts.value.map(mapLatestPost))
|
||||
|
||||
const featuredTrackRef = ref(null)
|
||||
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
|
||||
const featuredAtStart = ref(true)
|
||||
/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */
|
||||
const featuredAtEnd = ref(true)
|
||||
|
||||
let unbindFeaturedScroll = () => {}
|
||||
|
||||
/**
|
||||
* Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
|
||||
* @param {HTMLElement | null} el - 스크롤 컨테이너
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateFeaturedScrollEdges = (el) => {
|
||||
const target = el || featuredTrackRef.value
|
||||
|
||||
if (!target) {
|
||||
featuredAtStart.value = true
|
||||
featuredAtEnd.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = target
|
||||
const maxScroll = Math.max(0, scrollWidth - clientWidth)
|
||||
const epsilon = 2
|
||||
|
||||
featuredAtStart.value = scrollLeft <= epsilon
|
||||
featuredAtEnd.value = scrollLeft >= maxScroll - epsilon
|
||||
}
|
||||
|
||||
watch(featuredTrackRef, (el) => {
|
||||
unbindFeaturedScroll()
|
||||
unbindFeaturedScroll = () => {}
|
||||
|
||||
if (!import.meta.client || !el) {
|
||||
updateFeaturedScrollEdges(null)
|
||||
return
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
updateFeaturedScrollEdges(el)
|
||||
}
|
||||
|
||||
onScroll()
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
const resizeObserver = new ResizeObserver(onScroll)
|
||||
resizeObserver.observe(el)
|
||||
unbindFeaturedScroll = () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(featuredPosts, () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
updateFeaturedScrollEdges(featuredTrackRef.value)
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) {
|
||||
@@ -107,6 +168,7 @@ onBeforeUnmount(() => {
|
||||
return
|
||||
}
|
||||
|
||||
unbindFeaturedScroll()
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
@@ -120,6 +182,14 @@ const scrollFeatured = (direction) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'left' && featuredAtStart.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'right' && featuredAtEnd.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]')
|
||||
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244
|
||||
const gap = 24
|
||||
@@ -161,13 +231,29 @@ const scrollFeatured = (direction) => {
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
<div class="flex justify-between gap-2">
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Previous" @click="scrollFeatured('left')">‹</button>
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Next" @click="scrollFeatured('right')">›</button>
|
||||
<button
|
||||
class="featured-nav-prev cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 이전"
|
||||
:disabled="featuredAtStart"
|
||||
@click="scrollFeatured('left')"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="featured-nav-next cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 다음"
|
||||
:disabled="featuredAtEnd"
|
||||
@click="scrollFeatured('right')"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="featuredTrackRef"
|
||||
class="mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth pb-1 [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
class="featured-posts-track mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain scroll-smooth pb-1 touch-pan-x [-webkit-overflow-scrolling:touch] [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="post in featuredPosts"
|
||||
|
||||
Reference in New Issue
Block a user