diff --git a/src/components/ProjectsSection.module.css b/src/components/ProjectsSection.module.css index b27ef3c..61b9200 100644 --- a/src/components/ProjectsSection.module.css +++ b/src/components/ProjectsSection.module.css @@ -258,29 +258,49 @@ margin-top: 2.5rem; } -.progressTrack { +.scrollbarTrack { position: relative; - overflow: hidden; width: 30rem; max-width: 100%; height: 0.375rem; border-radius: var(--radius-pill); background: var(--color-blue-200); + cursor: pointer; + touch-action: none; } -.progressValue { - display: block; +.scrollbarTrack::before { + content: ''; + position: absolute; + inset: -0.75rem 0; +} + +.scrollbarTrack:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 0.5rem; +} + +.scrollbarThumb { + position: absolute; + top: 0; + left: 0; + z-index: 1; height: 100%; border-radius: var(--radius-pill); background: var(--color-blue-400); - transition: width var(--motion-duration-default) var(--motion-ease-out); + cursor: grab; + will-change: transform; } -[data-theme="dark"] .progressTrack { +.scrollbarDragging .scrollbarThumb { + cursor: grabbing; +} + +[data-theme="dark"] .scrollbarTrack { background: var(--color-blue-800); } -[data-theme="dark"] .progressValue { +[data-theme="dark"] .scrollbarThumb { background: var(--color-blue-300); } @@ -298,7 +318,7 @@ width: clamp(18.125rem, 23vw, 20.625rem); } - .progressTrack { + .scrollbarTrack { width: min(30rem, 50vw); } } @@ -369,12 +389,10 @@ } .scrollbarWrapper { - display: flex; - justify-content: center; margin-top: 2rem; } - .progressTrack { + .scrollbarTrack { width: min(22.5rem, 60vw); } } @@ -458,12 +476,10 @@ } .scrollbarWrapper { - display: flex; - justify-content: center; margin-top: 1.5rem; } - .progressTrack { + .scrollbarTrack { width: min(12.5rem, 60vw); } } diff --git a/src/components/ProjectsSection.tsx b/src/components/ProjectsSection.tsx index ec5ffc1..2137193 100644 --- a/src/components/ProjectsSection.tsx +++ b/src/components/ProjectsSection.tsx @@ -6,6 +6,8 @@ import { projects } from '@/data/projects'; import { ProjectThumbnail } from './ProjectThumbnail'; import styles from './ProjectsSection.module.css'; +const MIN_SCROLLBAR_THUMB_PERCENT = 12.5; + export const ProjectsSection: React.FC = () => { const viewportRef = useRef(null); const dragState = useRef({ @@ -20,8 +22,17 @@ export const ProjectsSection: React.FC = () => { pointerId: -1, directionLocked: null as 'horizontal' | 'vertical' | null, }); + const scrollbarDragState = useRef({ + active: false, + pointerId: -1, + grabOffset: 0, + }); const [isDragging, setIsDragging] = useState(false); + const [isScrollbarDragging, setIsScrollbarDragging] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); + const [scrollbarThumbPercent, setScrollbarThumbPercent] = useState( + MIN_SCROLLBAR_THUMB_PERCENT, + ); const [canScrollPrev, setCanScrollPrev] = useState(false); const [canScrollNext, setCanScrollNext] = useState(true); @@ -30,9 +41,16 @@ export const ProjectsSection: React.FC = () => { if (!viewport) return; const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth); - const progress = maxScroll > 0 ? (viewport.scrollLeft / maxScroll) * 100 : 0; - setScrollProgress(progress); + setScrollProgress( + maxScroll > 0 ? Math.max(0, Math.min(viewport.scrollLeft / maxScroll, 1)) : 0, + ); + setScrollbarThumbPercent( + Math.max( + MIN_SCROLLBAR_THUMB_PERCENT, + Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100), + ), + ); setCanScrollPrev(viewport.scrollLeft > 1); setCanScrollNext(viewport.scrollLeft < maxScroll - 1); }; @@ -196,6 +214,94 @@ export const ProjectsSection: React.FC = () => { } }; + const scrollFromScrollbarPointer = ( + track: HTMLDivElement, + clientX: number, + grabOffset: number, + ) => { + const viewport = viewportRef.current; + if (!viewport) return; + + const trackRect = track.getBoundingClientRect(); + const thumb = track.firstElementChild as HTMLElement | null; + const thumbWidth = thumb?.getBoundingClientRect().width ?? 0; + const availableTrack = trackRect.width - thumbWidth; + const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth); + if (availableTrack <= 0 || maxScroll <= 0) return; + + const thumbLeft = Math.max( + 0, + Math.min(clientX - trackRect.left - grabOffset, availableTrack), + ); + const nextProgress = thumbLeft / availableTrack; + setScrollProgress(nextProgress); + viewport.scrollLeft = nextProgress * maxScroll; + }; + + const handleScrollbarPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0 && event.pointerType === 'mouse') return; + + const track = event.currentTarget; + const thumb = track.firstElementChild as HTMLElement | null; + if (!thumb) return; + + const thumbRect = thumb.getBoundingClientRect(); + const clickedThumb = thumb.contains(event.target as Node); + const grabOffset = clickedThumb + ? event.clientX - thumbRect.left + : thumbRect.width / 2; + + scrollbarDragState.current = { + active: true, + pointerId: event.pointerId, + grabOffset, + }; + track.setPointerCapture(event.pointerId); + setIsScrollbarDragging(true); + + if (!clickedThumb) { + scrollFromScrollbarPointer(track, event.clientX, grabOffset); + } + event.preventDefault(); + }; + + const handleScrollbarPointerMove = (event: React.PointerEvent) => { + const drag = scrollbarDragState.current; + if (!drag.active || drag.pointerId !== event.pointerId) return; + + scrollFromScrollbarPointer(event.currentTarget, event.clientX, drag.grabOffset); + }; + + const stopScrollbarDragging = (event: React.PointerEvent) => { + const drag = scrollbarDragState.current; + if (!drag.active || drag.pointerId !== event.pointerId) return; + + drag.active = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + setIsScrollbarDragging(false); + }; + + const handleScrollbarKeyDown = (event: React.KeyboardEvent) => { + const viewport = viewportRef.current; + if (!viewport) return; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollByCard(-1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollByCard(1); + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + viewport.scrollTo({ + left: event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth, + behavior: 'smooth', + }); + } + }; + return (
{