fix(projects): add mobile edge spacing, 1-card layout, and smooth touch drag physics
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user