feat(projects): add accessible draggable carousel scrollbar
This commit is contained in:
@@ -258,29 +258,49 @@
|
|||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressTrack {
|
.scrollbarTrack {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
width: 30rem;
|
width: 30rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 0.375rem;
|
height: 0.375rem;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--color-blue-200);
|
background: var(--color-blue-200);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressValue {
|
.scrollbarTrack::before {
|
||||||
display: block;
|
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%;
|
height: 100%;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--color-blue-400);
|
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);
|
background: var(--color-blue-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .progressValue {
|
[data-theme="dark"] .scrollbarThumb {
|
||||||
background: var(--color-blue-300);
|
background: var(--color-blue-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +318,7 @@
|
|||||||
width: clamp(18.125rem, 23vw, 20.625rem);
|
width: clamp(18.125rem, 23vw, 20.625rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressTrack {
|
.scrollbarTrack {
|
||||||
width: min(30rem, 50vw);
|
width: min(30rem, 50vw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,12 +389,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbarWrapper {
|
.scrollbarWrapper {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressTrack {
|
.scrollbarTrack {
|
||||||
width: min(22.5rem, 60vw);
|
width: min(22.5rem, 60vw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,12 +476,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbarWrapper {
|
.scrollbarWrapper {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressTrack {
|
.scrollbarTrack {
|
||||||
width: min(12.5rem, 60vw);
|
width: min(12.5rem, 60vw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { projects } from '@/data/projects';
|
|||||||
import { ProjectThumbnail } from './ProjectThumbnail';
|
import { ProjectThumbnail } from './ProjectThumbnail';
|
||||||
import styles from './ProjectsSection.module.css';
|
import styles from './ProjectsSection.module.css';
|
||||||
|
|
||||||
|
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
|
||||||
|
|
||||||
export const ProjectsSection: React.FC = () => {
|
export const ProjectsSection: React.FC = () => {
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const dragState = useRef({
|
const dragState = useRef({
|
||||||
@@ -20,8 +22,17 @@ export const ProjectsSection: React.FC = () => {
|
|||||||
pointerId: -1,
|
pointerId: -1,
|
||||||
directionLocked: null as 'horizontal' | 'vertical' | null,
|
directionLocked: null as 'horizontal' | 'vertical' | null,
|
||||||
});
|
});
|
||||||
|
const scrollbarDragState = useRef({
|
||||||
|
active: false,
|
||||||
|
pointerId: -1,
|
||||||
|
grabOffset: 0,
|
||||||
|
});
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isScrollbarDragging, setIsScrollbarDragging] = useState(false);
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
const [scrollbarThumbPercent, setScrollbarThumbPercent] = useState(
|
||||||
|
MIN_SCROLLBAR_THUMB_PERCENT,
|
||||||
|
);
|
||||||
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||||
const [canScrollNext, setCanScrollNext] = useState(true);
|
const [canScrollNext, setCanScrollNext] = useState(true);
|
||||||
|
|
||||||
@@ -30,9 +41,16 @@ export const ProjectsSection: React.FC = () => {
|
|||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
|
|
||||||
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
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);
|
setCanScrollPrev(viewport.scrollLeft > 1);
|
||||||
setCanScrollNext(viewport.scrollLeft < maxScroll - 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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
const drag = scrollbarDragState.current;
|
||||||
|
if (!drag.active || drag.pointerId !== event.pointerId) return;
|
||||||
|
|
||||||
|
scrollFromScrollbarPointer(event.currentTarget, event.clientX, drag.grabOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopScrollbarDragging = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id="projects"
|
id="projects"
|
||||||
@@ -238,6 +344,7 @@ export const ProjectsSection: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
id="projects-carousel"
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className={`${styles.carouselViewport} ${isDragging ? styles.dragging : ''}`}
|
className={`${styles.carouselViewport} ${isDragging ? styles.dragging : ''}`}
|
||||||
onScroll={syncScrollState}
|
onScroll={syncScrollState}
|
||||||
@@ -301,16 +408,29 @@ export const ProjectsSection: React.FC = () => {
|
|||||||
|
|
||||||
<div className={styles.scrollbarWrapper} data-reveal="soft">
|
<div className={styles.scrollbarWrapper} data-reveal="soft">
|
||||||
<div
|
<div
|
||||||
className={styles.progressTrack}
|
className={`${styles.scrollbarTrack} ${isScrollbarDragging ? styles.scrollbarDragging : ''}`}
|
||||||
role="progressbar"
|
role="scrollbar"
|
||||||
|
tabIndex={0}
|
||||||
aria-label="Project carousel position"
|
aria-label="Project carousel position"
|
||||||
|
aria-controls="projects-carousel"
|
||||||
|
aria-orientation="horizontal"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
aria-valuenow={Math.round(scrollProgress)}
|
aria-valuenow={Math.round(scrollProgress * 100)}
|
||||||
|
onPointerDown={handleScrollbarPointerDown}
|
||||||
|
onPointerMove={handleScrollbarPointerMove}
|
||||||
|
onPointerUp={stopScrollbarDragging}
|
||||||
|
onPointerCancel={stopScrollbarDragging}
|
||||||
|
onKeyDown={handleScrollbarKeyDown}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={styles.progressValue}
|
className={styles.scrollbarThumb}
|
||||||
style={{ width: `${12.5 + scrollProgress * 0.875}%` }}
|
style={{
|
||||||
|
width: `${scrollbarThumbPercent}%`,
|
||||||
|
transform: `translate3d(${scrollbarThumbPercent < 100
|
||||||
|
? scrollProgress * ((100 - scrollbarThumbPercent) / scrollbarThumbPercent) * 100
|
||||||
|
: 0}%, 0, 0)`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user