fix(projects): add mobile edge spacing, 1-card layout, and smooth touch drag physics

This commit is contained in:
2026-09-06 13:37:45 +03:30
parent a1cab65d1e
commit c7ef6dff3e
2 changed files with 303 additions and 62 deletions

View File

@@ -75,6 +75,8 @@
overscroll-behavior-inline: contain; overscroll-behavior-inline: contain;
touch-action: pan-y; touch-action: pan-y;
user-select: none; user-select: none;
container-type: inline-size;
container-name: carousel;
} }
.carouselViewport::-webkit-scrollbar { .carouselViewport::-webkit-scrollbar {
@@ -89,23 +91,28 @@
.dragging { .dragging {
cursor: grabbing; cursor: grabbing;
scroll-snap-type: none !important;
scroll-behavior: auto !important;
user-select: none;
} }
.carouselTrack { .carouselTrack {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
width: max-content; width: max-content;
gap: 24px; gap: 20px;
padding: 4px 0 8px; padding: 4px 0 8px;
} }
.card { .card {
display: flex; display: flex;
flex: 0 0 var(--size-project-width); flex: 0 0 clamp(300px, 24vw, 340px);
flex-shrink: 0;
flex-direction: column; flex-direction: column;
width: var(--size-project-width); width: clamp(300px, 24vw, 340px);
min-height: 480px; min-height: 460px;
overflow: hidden; overflow: hidden;
box-sizing: border-box;
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--bg-surface); background: var(--bg-surface);
@@ -116,13 +123,14 @@
.mediaContainer { .mediaContainer {
position: relative; position: relative;
width: 100%; width: 100%;
height: var(--size-project-media-height); aspect-ratio: 16 / 10;
overflow: hidden; overflow: hidden;
background: var(--color-blue-900); background: var(--color-blue-900);
} }
.projectImage { .projectImage {
object-fit: cover; object-fit: cover;
object-position: center;
pointer-events: none; pointer-events: none;
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
@@ -131,7 +139,7 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
padding: 24px; padding: 18px 16px;
} }
.cardTitleRow { .cardTitleRow {
@@ -143,9 +151,9 @@
.cardTitle { .cardTitle {
color: var(--text-primary); color: var(--text-primary);
font-size: var(--font-size-h3); font-size: 18px;
font-weight: 700; font-weight: 700;
line-height: var(--line-height-h3); line-height: 1.25;
} }
.titleLinkIcon { .titleLinkIcon {
@@ -158,9 +166,9 @@
} }
.cardDate { .cardDate {
margin-bottom: 14px; margin-bottom: 10px;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--font-size-label-small); font-size: 11px;
font-weight: 700; font-weight: 700;
line-height: var(--line-height-label-small); line-height: var(--line-height-label-small);
letter-spacing: var(--letter-spacing-eyebrow); letter-spacing: var(--letter-spacing-eyebrow);
@@ -169,21 +177,24 @@
.cardDesc { .cardDesc {
display: -webkit-box; display: -webkit-box;
margin-bottom: 20px; margin-bottom: 16px;
overflow: hidden; overflow: hidden;
color: var(--text-primary); color: var(--text-primary);
font-size: var(--font-size-body-small); font-size: 13.5px;
font-weight: 400; font-weight: 400;
line-height: var(--line-height-body-small); line-height: 1.55;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 4; -webkit-line-clamp: 3;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
} }
.tagList { .tagList {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 6px;
margin-bottom: 24px; margin-bottom: 18px;
} }
.tagBadge { .tagBadge {
@@ -233,7 +244,9 @@
} }
.scrollbar { .scrollbar {
--thumb-width: 216px;
width: 480px; width: 480px;
max-width: 100%;
height: 20px; height: 20px;
margin: 0; margin: 0;
appearance: none; appearance: none;
@@ -248,7 +261,7 @@
} }
.scrollbar::-webkit-slider-thumb { .scrollbar::-webkit-slider-thumb {
width: 216px; width: var(--thumb-width, 216px);
height: 6px; height: 6px;
margin-top: 0; margin-top: 0;
appearance: none; appearance: none;
@@ -265,7 +278,7 @@
} }
.scrollbar::-moz-range-thumb { .scrollbar::-moz-range-thumb {
width: 216px; width: var(--thumb-width, 216px);
height: 6px; height: 6px;
border: 0; border: 0;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
@@ -294,65 +307,192 @@
background: var(--color-blue-300); background: var(--color-blue-300);
} }
@media (max-width: 1024px) { /* ================================================================
LAPTOP 1101px – 1440px
Comfortable ~290px-330px cards (~3 to 4 visible)
================================================================ */
@media (max-width: 1440px) and (min-width: 1101px) {
.carouselTrack {
gap: 20px;
}
.card {
flex: 0 0 clamp(290px, 23vw, 330px);
width: clamp(290px, 23vw, 330px);
}
.scrollbar {
width: min(480px, 50vw);
--thumb-width: 120px;
}
}
/* ================================================================
TABLET 651px – 1100px
Comfortable ~310px-350px cards (~2 cards visible)
================================================================ */
@media (max-width: 1100px) and (min-width: 651px) {
.projectsSection { .projectsSection {
padding-top: var(--space-8); padding-top: var(--space-8);
padding-bottom: var(--space-9); padding-bottom: var(--space-9);
} }
.carouselTrack {
gap: var(--space-4);
}
.card {
flex-basis: min(340px, calc(100vw - (2 * var(--layout-page-gutter))));
width: min(340px, calc(100vw - (2 * var(--layout-page-gutter))));
}
}
@media (max-width: 640px) {
.headerRow { .headerRow {
align-items: stretch; align-items: flex-start;
flex-direction: column; justify-content: space-between;
gap: var(--space-3); margin-bottom: 32px;
margin-bottom: var(--space-6);
}
.arrowButtons {
align-self: flex-end;
gap: var(--space-2);
}
.navButton {
width: 40px;
height: 40px;
} }
.carouselViewport { .carouselViewport {
margin-inline: calc(-1 * var(--layout-page-gutter));
width: calc(100% + (2 * var(--layout-page-gutter)));
padding-inline: var(--layout-page-gutter);
scroll-padding-inline: var(--layout-page-gutter);
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-padding-inline: 0;
}
.carouselTrack {
gap: 20px;
} }
.card { .card {
flex-basis: min(320px, calc(100vw - 40px)); flex: 0 0 clamp(310px, 42vw, 350px);
width: min(320px, calc(100vw - 40px)); width: clamp(310px, 42vw, 350px);
min-height: 460px; min-height: 450px;
box-sizing: border-box;
scroll-snap-align: start;
} }
.mediaContainer { .mediaContainer {
height: 200px; aspect-ratio: 16 / 10;
} }
.cardBody { .cardBody {
padding: var(--space-5); padding: 18px 16px;
}
.cardTitle {
font-size: 18px;
}
.cardDesc {
font-size: 13.5px;
line-height: 1.55;
margin-bottom: 14px;
-webkit-line-clamp: 3;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
.tagList {
gap: 6px;
margin-bottom: 16px;
}
.tagBadge {
padding: 3px 9px;
font-size: 11px;
} }
.scrollbarWrapper { .scrollbarWrapper {
display: flex;
justify-content: center;
margin-top: 32px;
}
.scrollbar {
width: min(360px, 60vw);
--thumb-width: 90px;
}
}
/* ================================================================
MOBILE ≤ 650px
Comfortable ~310px-330px card (~1 card visible with clean margins)
================================================================ */
@media (max-width: 650px) {
.projectsSection {
padding-top: 48px;
padding-bottom: 64px;
}
.headerRow {
margin-bottom: 24px;
}
.arrowButtons {
display: none; display: none;
} }
.carouselViewport {
width: 100%;
margin-inline: 0;
padding-inline: 0;
scroll-padding-inline: 0;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.carouselTrack {
gap: 16px;
width: max-content;
}
.card {
flex: 0 0 calc(100vw - (2 * var(--layout-page-gutter)));
flex: 0 0 100cqw;
width: calc(100vw - (2 * var(--layout-page-gutter)));
width: 100cqw;
min-width: calc(100vw - (2 * var(--layout-page-gutter)));
min-width: 100cqw;
max-width: 100%;
min-height: 420px;
box-sizing: border-box;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.mediaContainer {
aspect-ratio: 16 / 10;
}
.cardBody {
padding: 18px 16px;
}
.cardTitle {
font-size: 18px;
}
.cardDesc {
font-size: 13px;
line-height: 1.5;
margin-bottom: 14px;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
-webkit-line-clamp: 3;
}
.tagList {
gap: 6px;
margin-bottom: 16px;
}
.tagBadge {
padding: 3px 9px;
font-size: 11px;
}
.scrollbarWrapper {
display: flex;
justify-content: center;
margin-top: 24px;
}
.scrollbar {
width: min(200px, 60vw);
height: 16px;
--thumb-width: 60px;
}
} }
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {

View File

@@ -12,7 +12,13 @@ export const ProjectsSection: React.FC = () => {
active: false, active: false,
moved: false, moved: false,
startX: 0, startX: 0,
startY: 0,
startScrollLeft: 0, startScrollLeft: 0,
lastX: 0,
lastTime: 0,
velocity: 0,
pointerId: -1,
directionLocked: null as 'horizontal' | 'vertical' | null,
}); });
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
@@ -51,7 +57,7 @@ export const ProjectsSection: React.FC = () => {
}; };
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return; if (event.button !== 0 && event.pointerType === 'mouse') return;
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport) return; if (!viewport) return;
@@ -60,30 +66,112 @@ export const ProjectsSection: React.FC = () => {
active: true, active: true,
moved: false, moved: false,
startX: event.clientX, startX: event.clientX,
startY: event.clientY,
startScrollLeft: viewport.scrollLeft, startScrollLeft: viewport.scrollLeft,
lastX: event.clientX,
lastTime: performance.now(),
velocity: 0,
pointerId: event.pointerId,
directionLocked: null,
}; };
viewport.setPointerCapture(event.pointerId);
setIsDragging(true);
}; };
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) return; if (!viewport || !dragState.current.active) return;
const distance = event.clientX - dragState.current.startX; const deltaX = event.clientX - dragState.current.startX;
if (Math.abs(distance) > 3) dragState.current.moved = true; const deltaY = event.clientY - dragState.current.startY;
viewport.scrollLeft = dragState.current.startScrollLeft - distance;
// Detect user intent: horizontal carousel swipe vs vertical page scroll
if (!dragState.current.directionLocked) {
if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
// User intends to scroll page vertically, release touch
dragState.current.directionLocked = 'vertical';
dragState.current.active = false;
return;
}
// User intends horizontal carousel drag
dragState.current.directionLocked = 'horizontal';
try {
viewport.setPointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture unavailable
}
setIsDragging(true);
}
if (dragState.current.directionLocked === 'horizontal') {
const now = performance.now();
const dt = Math.max(now - dragState.current.lastTime, 1);
const dx = event.clientX - dragState.current.lastX;
dragState.current.velocity = dx / dt;
dragState.current.lastX = event.clientX;
dragState.current.lastTime = now;
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
viewport.scrollLeft = dragState.current.startScrollLeft - deltaX;
}
}; };
const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => { const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) return; if (!viewport || !dragState.current.active) {
setIsDragging(false);
return;
}
const wasHorizontal = dragState.current.directionLocked === 'horizontal';
const moved = dragState.current.moved;
const velocity = dragState.current.velocity;
const clientX = event.clientX ?? dragState.current.lastX;
const totalDeltaX = clientX - dragState.current.startX;
const pointerId = dragState.current.pointerId;
dragState.current.active = false; dragState.current.active = false;
if (viewport.hasPointerCapture(event.pointerId)) { if (viewport.hasPointerCapture(pointerId)) {
viewport.releasePointerCapture(event.pointerId); try {
viewport.releasePointerCapture(pointerId);
} catch {
// Ignore
}
} }
setIsDragging(false); setIsDragging(false);
if (!wasHorizontal || !moved) return;
// Calculate snap target with velocity fling
const track = viewport.firstElementChild as HTMLElement | null;
const card = track?.firstElementChild as HTMLElement | null;
if (!track || !card) return;
const cardWidth = card.getBoundingClientRect().width;
const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0;
const step = cardWidth + gap;
if (step <= 0) return;
const currentScroll = viewport.scrollLeft;
let targetIndex: number;
if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) {
if (totalDeltaX < 0 || velocity < -0.2) {
targetIndex = Math.ceil(currentScroll / step);
} else {
targetIndex = Math.floor(currentScroll / step);
}
} else {
targetIndex = Math.round(currentScroll / step);
}
const maxIndex = projects.length - 1;
targetIndex = Math.max(0, Math.min(targetIndex, maxIndex));
viewport.scrollTo({
left: targetIndex * step,
behavior: 'smooth',
});
}; };
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => { const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -102,6 +190,16 @@ export const ProjectsSection: React.FC = () => {
viewport.scrollLeft = (Number(event.target.value) / 100) * maxScroll; viewport.scrollLeft = (Number(event.target.value) / 100) * maxScroll;
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollByCard(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollByCard(1);
}
};
return ( return (
<section <section
id="projects" id="projects"
@@ -152,6 +250,7 @@ export const ProjectsSection: React.FC = () => {
onPointerUp={stopDragging} onPointerUp={stopDragging}
onPointerCancel={stopDragging} onPointerCancel={stopDragging}
onClickCapture={handleClickCapture} onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown}
tabIndex={0} tabIndex={0}
aria-label="Scrollable project cards" aria-label="Scrollable project cards"
> >
@@ -163,6 +262,8 @@ export const ProjectsSection: React.FC = () => {
src={project.image} src={project.image}
alt={project.title} alt={project.title}
fill fill
sizes="(max-width: 650px) 100vw, (max-width: 1100px) 100vw, 360px"
priority={project.id === 'qlockify'}
draggable={false} draggable={false}
className={styles.projectImage} className={styles.projectImage}
/> />