Skip to content

Commit c33f900

Browse files
committed
feat: improve mobile navigation for ProfessionalJourney
- Move navigation buttons to bottom center on mobile - Show only 2 items visible in mobile view (instead of 3) - Add larger, touch-friendly buttons and indicators - Extract reusable SlideNavigation component to eliminate duplication
1 parent 5f8820a commit c33f900

7 files changed

Lines changed: 352 additions & 59 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use client';
2+
3+
4+
export interface SlideNavigationProps {
5+
currentIndex: number;
6+
maxIndex: number;
7+
onPrev: () => void;
8+
onNext: () => void;
9+
onGoToIndex: (index: number) => void;
10+
variant?: 'desktop' | 'mobile';
11+
className?: string;
12+
}
13+
14+
export function SlideNavigation({
15+
currentIndex,
16+
maxIndex,
17+
onPrev,
18+
onNext,
19+
onGoToIndex,
20+
variant = 'desktop',
21+
className = '',
22+
}: SlideNavigationProps) {
23+
const isMobile = variant === 'mobile';
24+
const buttonSize = isMobile ? 'p-3' : 'p-2';
25+
const iconSize = isMobile ? 'w-6 h-6' : 'w-5 h-5';
26+
const indicatorSize = isMobile ? 'w-3 h-3' : 'w-2 h-2';
27+
const indicatorSpacing = isMobile ? 'space-x-2' : 'space-x-1';
28+
29+
const navigationDots = Array.from({ length: maxIndex + 1 }, (_, index) => index);
30+
31+
return (
32+
<div className={`flex items-center ${isMobile ? 'justify-center space-x-4' : 'space-x-2'} ${className}`}>
33+
<button
34+
onClick={onPrev}
35+
disabled={currentIndex === 0}
36+
className={`${buttonSize} rounded-full bg-[#f6f8fa] dark:bg-[#21262d] border border-[#d0d7de] dark:border-[#30363d] hover:bg-[#e9ecef] dark:hover:bg-[#30363d] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200`}
37+
data-testid={isMobile ? "mobile-prev-slide-button" : "prev-slide-button"}
38+
aria-label="Previous milestone"
39+
>
40+
<svg className={`${iconSize} text-[#24292f] dark:text-[#f0f6fc]`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
41+
<path
42+
strokeLinecap="round"
43+
strokeLinejoin="round"
44+
strokeWidth={2}
45+
d="M15 19l-7-7 7-7"
46+
/>
47+
</svg>
48+
</button>
49+
50+
<div className={`flex ${indicatorSpacing}`} data-testid={isMobile ? "mobile-slide-indicators" : "slide-indicators"}>
51+
{navigationDots.map((dotIndex) => (
52+
<button
53+
key={dotIndex}
54+
onClick={() => onGoToIndex(dotIndex)}
55+
className={`${indicatorSize} rounded-full transition-all duration-200 ${
56+
currentIndex === dotIndex
57+
? 'bg-[#0969da] dark:bg-[#58a6ff]'
58+
: 'bg-[#d0d7de] dark:bg-[#30363d] hover:bg-[#b1b8c0] dark:hover:bg-[#656d76]'
59+
}`}
60+
data-testid={isMobile ? `mobile-slide-indicator-${dotIndex}` : `slide-indicator-${dotIndex}`}
61+
aria-label={`Show milestones starting from position ${dotIndex + 1}`}
62+
/>
63+
))}
64+
</div>
65+
66+
<button
67+
onClick={onNext}
68+
disabled={currentIndex === maxIndex}
69+
className={`${buttonSize} rounded-full bg-[#f6f8fa] dark:bg-[#21262d] border border-[#d0d7de] dark:border-[#30363d] hover:bg-[#e9ecef] dark:hover:bg-[#30363d] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200`}
70+
data-testid={isMobile ? "mobile-next-slide-button" : "next-slide-button"}
71+
aria-label="Next milestone"
72+
>
73+
<svg className={`${iconSize} text-[#24292f] dark:text-[#f0f6fc]`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
74+
<path
75+
strokeLinecap="round"
76+
strokeLinejoin="round"
77+
strokeWidth={2}
78+
d="M9 5l7 7-7 7"
79+
/>
80+
</svg>
81+
</button>
82+
</div>
83+
);
84+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
2+
export interface SlideNavigationProps {
3+
currentIndex: number;
4+
maxIndex: number;
5+
onPrev: () => void;
6+
onNext: () => void;
7+
onGoToIndex: (index: number) => void;
8+
variant?: 'desktop' | 'mobile';
9+
className?: string;
10+
}
11+
12+
export function SlideNavigation({
13+
currentIndex,
14+
maxIndex,
15+
onPrev,
16+
onNext,
17+
onGoToIndex,
18+
variant = 'desktop',
19+
className = '',
20+
}: SlideNavigationProps) {
21+
const isMobile = variant === 'mobile';
22+
const testIdPrefix = isMobile ? 'mobile' : '';
23+
24+
return (
25+
<div className={`slide-navigation ${className}`} data-testid={`${testIdPrefix}slide-navigation`}>
26+
<button
27+
onClick={onPrev}
28+
disabled={currentIndex === 0}
29+
data-testid={`${testIdPrefix}prev-slide-button`}
30+
aria-label="Previous milestone"
31+
>
32+
Previous
33+
</button>
34+
35+
<div data-testid={`${testIdPrefix}slide-indicators`}>
36+
{Array.from({ length: maxIndex + 1 }, (_, index) => (
37+
<button
38+
key={index}
39+
onClick={() => onGoToIndex(index)}
40+
data-testid={`${testIdPrefix}slide-indicator-${index}`}
41+
aria-label={`Show milestones starting from position ${index + 1}`}
42+
>
43+
{index}
44+
</button>
45+
))}
46+
</div>
47+
48+
<button
49+
onClick={onNext}
50+
disabled={currentIndex === maxIndex}
51+
data-testid={`${testIdPrefix}next-slide-button`}
52+
aria-label="Next milestone"
53+
>
54+
Next
55+
</button>
56+
</div>
57+
);
58+
}

src/components/micro/__mocks__/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './Icon';
88
export * from './OthersIconList';
99
export * from './ProjectCard';
1010
export * from './ProgressBar';
11+
export * from './SlideNavigation';
1112
export * from './ThemeToggle';
1213
export * from './Typography';
1314
export * from './Typewriter';
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { SlideNavigation, type SlideNavigationProps } from "../SlideNavigation";
4+
5+
describe("SlideNavigation", () => {
6+
const defaultProps: SlideNavigationProps = {
7+
currentIndex: 0,
8+
maxIndex: 2,
9+
onPrev: jest.fn(),
10+
onNext: jest.fn(),
11+
onGoToIndex: jest.fn(),
12+
};
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it("renders desktop navigation by default", () => {
19+
render(<SlideNavigation {...defaultProps} />);
20+
21+
expect(screen.getByTestId("prev-slide-button")).toBeInTheDocument();
22+
expect(screen.getByTestId("next-slide-button")).toBeInTheDocument();
23+
expect(screen.getByTestId("slide-indicators")).toBeInTheDocument();
24+
});
25+
26+
it("renders mobile navigation when variant is mobile", () => {
27+
render(<SlideNavigation {...defaultProps} variant="mobile" />);
28+
29+
expect(screen.getByTestId("mobile-prev-slide-button")).toBeInTheDocument();
30+
expect(screen.getByTestId("mobile-next-slide-button")).toBeInTheDocument();
31+
expect(screen.getByTestId("mobile-slide-indicators")).toBeInTheDocument();
32+
});
33+
34+
it("calls onPrev when prev button is clicked", async () => {
35+
const user = userEvent.setup();
36+
render(<SlideNavigation {...defaultProps} currentIndex={1} />);
37+
38+
const prevButton = screen.getByTestId("prev-slide-button");
39+
await user.click(prevButton);
40+
41+
expect(defaultProps.onPrev).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it("calls onNext when next button is clicked", async () => {
45+
const user = userEvent.setup();
46+
render(<SlideNavigation {...defaultProps} />);
47+
48+
const nextButton = screen.getByTestId("next-slide-button");
49+
await user.click(nextButton);
50+
51+
expect(defaultProps.onNext).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("calls onGoToIndex when indicator is clicked", async () => {
55+
const user = userEvent.setup();
56+
render(<SlideNavigation {...defaultProps} />);
57+
58+
const secondIndicator = screen.getByTestId("slide-indicator-1");
59+
await user.click(secondIndicator);
60+
61+
expect(defaultProps.onGoToIndex).toHaveBeenCalledWith(1);
62+
});
63+
64+
it("disables prev button when currentIndex is 0", () => {
65+
render(<SlideNavigation {...defaultProps} currentIndex={0} />);
66+
67+
const prevButton = screen.getByTestId("prev-slide-button");
68+
expect(prevButton).toBeDisabled();
69+
});
70+
71+
it("disables next button when currentIndex equals maxIndex", () => {
72+
render(<SlideNavigation {...defaultProps} currentIndex={2} maxIndex={2} />);
73+
74+
const nextButton = screen.getByTestId("next-slide-button");
75+
expect(nextButton).toBeDisabled();
76+
});
77+
78+
it("enables prev button when currentIndex is greater than 0", () => {
79+
render(<SlideNavigation {...defaultProps} currentIndex={1} />);
80+
81+
const prevButton = screen.getByTestId("prev-slide-button");
82+
expect(prevButton).not.toBeDisabled();
83+
});
84+
85+
it("enables next button when currentIndex is less than maxIndex", () => {
86+
render(<SlideNavigation {...defaultProps} currentIndex={1} maxIndex={2} />);
87+
88+
const nextButton = screen.getByTestId("next-slide-button");
89+
expect(nextButton).not.toBeDisabled();
90+
});
91+
92+
it("renders correct number of indicators", () => {
93+
render(<SlideNavigation {...defaultProps} maxIndex={3} />);
94+
95+
expect(screen.getByTestId("slide-indicator-0")).toBeInTheDocument();
96+
expect(screen.getByTestId("slide-indicator-1")).toBeInTheDocument();
97+
expect(screen.getByTestId("slide-indicator-2")).toBeInTheDocument();
98+
expect(screen.getByTestId("slide-indicator-3")).toBeInTheDocument();
99+
});
100+
101+
it("works with mobile variant", async () => {
102+
const user = userEvent.setup();
103+
render(<SlideNavigation {...defaultProps} currentIndex={1} variant="mobile" />);
104+
105+
const mobilePrevButton = screen.getByTestId("mobile-prev-slide-button");
106+
const mobileNextButton = screen.getByTestId("mobile-next-slide-button");
107+
108+
await user.click(mobilePrevButton);
109+
expect(defaultProps.onPrev).toHaveBeenCalledTimes(1);
110+
111+
await user.click(mobileNextButton);
112+
expect(defaultProps.onNext).toHaveBeenCalledTimes(1);
113+
});
114+
115+
it("applies custom className", () => {
116+
render(<SlideNavigation {...defaultProps} className="custom-class" />);
117+
118+
const container = screen.getByTestId("prev-slide-button").parentElement;
119+
expect(container).toHaveClass("custom-class");
120+
});
121+
});

src/components/micro/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './Icon';
66
export * from './OthersIconList';
77
export * from './ProjectCard';
88
export * from './ProgressBar';
9+
export * from './SlideNavigation';
910
export * from './ThemeToggle';
1011
export * from './Typography';
1112
export * from './Typewriter';

0 commit comments

Comments
 (0)