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:
2026-05-11 12:29:27 +09:00
parent ed7709ab59
commit fd55d8af08
6 changed files with 108 additions and 5 deletions

View File

@@ -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 해석 오류

View File

@@ -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/링크복사) |

View File

@@ -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

View File

@@ -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`에서 해석하지 못하던 오류 수정.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.59",
"version": "0.0.60",
"private": true,
"type": "module",
"imports": {

View File

@@ -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"