Skip to content

Commit ac0ef2a

Browse files
authored
Merge pull request #75 from hiiragi17/claude/implement-issue-67-responsive-a
feat(layout): レスポンシブ基盤の追加 (#67 PR A)
2 parents c7940a2 + 93f786b commit ac0ef2a

8 files changed

Lines changed: 488 additions & 5 deletions

File tree

src/app/history/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export default function HistoryPage() {
154154
)}
155155

156156
{/* Stats */}
157-
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
157+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-8">
158158
<div className="bg-white rounded-lg shadow-sm p-6">
159159
<p className="text-sm text-gray-600 mb-1">総振り返り数</p>
160160
<p className="text-3xl font-bold text-gray-900">{reflections.length}</p>
@@ -194,9 +194,9 @@ export default function HistoryPage() {
194194
</button>
195195
</div>
196196
) : (
197-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
197+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
198198
{/* Calendar - Left side */}
199-
<div className="lg:col-span-1">
199+
<div className="md:col-span-1 lg:col-span-1">
200200
<Calendar
201201
reflections={reflections}
202202
frameworks={frameworks}
@@ -206,7 +206,7 @@ export default function HistoryPage() {
206206

207207
{/* Reflection Detail Panel - Right side */}
208208
{selectedDetail && (
209-
<div className="lg:col-span-2 bg-white rounded-lg shadow-sm overflow-hidden flex flex-col">
209+
<div className="md:col-span-1 lg:col-span-2 bg-white rounded-lg shadow-sm overflow-hidden flex flex-col">
210210
{/* Panel Header */}
211211
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between flex-shrink-0">
212212
<h2 className="text-xl font-bold text-gray-900">
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import ResponsiveGrid from './ResponsiveGrid';
4+
5+
describe('ResponsiveGrid', () => {
6+
it('applies grid with the specified base columns and gap', () => {
7+
const { container } = render(
8+
<ResponsiveGrid cols={2} gap={6}>
9+
<div>a</div>
10+
<div>b</div>
11+
</ResponsiveGrid>,
12+
);
13+
14+
const grid = container.firstElementChild as HTMLElement;
15+
expect(grid.className).toContain('grid');
16+
expect(grid.className).toContain('grid-cols-2');
17+
expect(grid.className).toContain('gap-6');
18+
});
19+
20+
it('applies responsive column classes for each breakpoint', () => {
21+
const { container } = render(
22+
<ResponsiveGrid cols={1} smCols={2} mdCols={3} lgCols={4} xlCols={6}>
23+
<div>a</div>
24+
</ResponsiveGrid>,
25+
);
26+
27+
const grid = container.firstElementChild as HTMLElement;
28+
expect(grid.className).toContain('sm:grid-cols-2');
29+
expect(grid.className).toContain('md:grid-cols-3');
30+
expect(grid.className).toContain('lg:grid-cols-4');
31+
expect(grid.className).toContain('xl:grid-cols-6');
32+
});
33+
34+
it('renders children and custom element', () => {
35+
const { container, getByText } = render(
36+
<ResponsiveGrid cols={1} as="section">
37+
<span>child</span>
38+
</ResponsiveGrid>,
39+
);
40+
41+
expect(container.firstElementChild?.tagName).toBe('SECTION');
42+
expect(getByText('child')).toBeInTheDocument();
43+
});
44+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { ReactNode } from 'react';
2+
import { cn } from '@/lib/utils';
3+
4+
type ColCount = 1 | 2 | 3 | 4 | 5 | 6;
5+
type GapSize = 2 | 3 | 4 | 6 | 8;
6+
7+
export interface ResponsiveGridProps {
8+
children: ReactNode;
9+
cols?: ColCount;
10+
smCols?: ColCount;
11+
mdCols?: ColCount;
12+
lgCols?: ColCount;
13+
xlCols?: ColCount;
14+
gap?: GapSize;
15+
className?: string;
16+
as?: 'div' | 'section' | 'ul';
17+
}
18+
19+
const baseColClasses: Record<ColCount, string> = {
20+
1: 'grid-cols-1',
21+
2: 'grid-cols-2',
22+
3: 'grid-cols-3',
23+
4: 'grid-cols-4',
24+
5: 'grid-cols-5',
25+
6: 'grid-cols-6',
26+
};
27+
28+
const smColClasses: Record<ColCount, string> = {
29+
1: 'sm:grid-cols-1',
30+
2: 'sm:grid-cols-2',
31+
3: 'sm:grid-cols-3',
32+
4: 'sm:grid-cols-4',
33+
5: 'sm:grid-cols-5',
34+
6: 'sm:grid-cols-6',
35+
};
36+
37+
const mdColClasses: Record<ColCount, string> = {
38+
1: 'md:grid-cols-1',
39+
2: 'md:grid-cols-2',
40+
3: 'md:grid-cols-3',
41+
4: 'md:grid-cols-4',
42+
5: 'md:grid-cols-5',
43+
6: 'md:grid-cols-6',
44+
};
45+
46+
const lgColClasses: Record<ColCount, string> = {
47+
1: 'lg:grid-cols-1',
48+
2: 'lg:grid-cols-2',
49+
3: 'lg:grid-cols-3',
50+
4: 'lg:grid-cols-4',
51+
5: 'lg:grid-cols-5',
52+
6: 'lg:grid-cols-6',
53+
};
54+
55+
const xlColClasses: Record<ColCount, string> = {
56+
1: 'xl:grid-cols-1',
57+
2: 'xl:grid-cols-2',
58+
3: 'xl:grid-cols-3',
59+
4: 'xl:grid-cols-4',
60+
5: 'xl:grid-cols-5',
61+
6: 'xl:grid-cols-6',
62+
};
63+
64+
const gapClasses: Record<GapSize, string> = {
65+
2: 'gap-2',
66+
3: 'gap-3',
67+
4: 'gap-4',
68+
6: 'gap-6',
69+
8: 'gap-8',
70+
};
71+
72+
export default function ResponsiveGrid({
73+
children,
74+
cols = 1,
75+
smCols,
76+
mdCols,
77+
lgCols,
78+
xlCols,
79+
gap = 4,
80+
className,
81+
as: Component = 'div',
82+
}: ResponsiveGridProps) {
83+
return (
84+
<Component
85+
className={cn(
86+
'grid',
87+
baseColClasses[cols],
88+
smCols && smColClasses[smCols],
89+
mdCols && mdColClasses[mdCols],
90+
lgCols && lgColClasses[lgCols],
91+
xlCols && xlColClasses[xlCols],
92+
gapClasses[gap],
93+
className,
94+
)}
95+
>
96+
{children}
97+
</Component>
98+
);
99+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import SidebarLayout from './SidebarLayout';
4+
5+
describe('SidebarLayout', () => {
6+
it('renders sidebar, main content, and optional right panel', () => {
7+
render(
8+
<SidebarLayout
9+
sidebar={<div>sidebar-content</div>}
10+
rightPanel={<div>right-panel</div>}
11+
>
12+
<div>main-content</div>
13+
</SidebarLayout>,
14+
);
15+
16+
expect(screen.getByText('sidebar-content')).toBeInTheDocument();
17+
expect(screen.getByText('main-content')).toBeInTheDocument();
18+
expect(screen.getByText('right-panel')).toBeInTheDocument();
19+
});
20+
21+
it('omits right panel when not provided', () => {
22+
const { container } = render(
23+
<SidebarLayout sidebar={<div>sidebar</div>}>
24+
<div>main</div>
25+
</SidebarLayout>,
26+
);
27+
28+
const asides = container.querySelectorAll('aside');
29+
expect(asides).toHaveLength(1);
30+
});
31+
32+
it('uses semantic main and aside landmarks', () => {
33+
render(
34+
<SidebarLayout sidebar={<div>s</div>}>
35+
<div>m</div>
36+
</SidebarLayout>,
37+
);
38+
39+
expect(screen.getByRole('main')).toBeInTheDocument();
40+
expect(screen.getByRole('complementary')).toBeInTheDocument();
41+
});
42+
43+
it('applies the sidebarWidth variant', () => {
44+
const { container } = render(
45+
<SidebarLayout sidebar={<div>s</div>} sidebarWidth="lg">
46+
<div>m</div>
47+
</SidebarLayout>,
48+
);
49+
50+
const aside = container.querySelector('aside');
51+
expect(aside?.className).toContain('md:w-72');
52+
});
53+
54+
it('assigns distinct aria-labels to left and right asides', () => {
55+
render(
56+
<SidebarLayout
57+
sidebar={<div>s</div>}
58+
rightPanel={<div>r</div>}
59+
sidebarAriaLabel="主要ナビゲーション"
60+
rightPanelAriaLabel="詳細パネル"
61+
>
62+
<div>m</div>
63+
</SidebarLayout>,
64+
);
65+
66+
expect(screen.getByRole('complementary', { name: '主要ナビゲーション' })).toBeInTheDocument();
67+
expect(screen.getByRole('complementary', { name: '詳細パネル' })).toBeInTheDocument();
68+
});
69+
70+
it('right panel has a bounded md width to prevent overflow', () => {
71+
const { container } = render(
72+
<SidebarLayout sidebar={<div>s</div>} rightPanel={<div>r</div>}>
73+
<div>m</div>
74+
</SidebarLayout>,
75+
);
76+
77+
const asides = container.querySelectorAll('aside');
78+
const rightAside = asides[asides.length - 1];
79+
expect(rightAside.className).toContain('md:w-64');
80+
expect(rightAside.className).toContain('lg:w-80');
81+
});
82+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ReactNode } from 'react';
2+
import { cn } from '@/lib/utils';
3+
4+
export interface SidebarLayoutProps {
5+
sidebar: ReactNode;
6+
children: ReactNode;
7+
rightPanel?: ReactNode;
8+
sidebarWidth?: 'sm' | 'md' | 'lg';
9+
className?: string;
10+
stickyBreakpoint?: 'md' | 'lg';
11+
sidebarAriaLabel?: string;
12+
rightPanelAriaLabel?: string;
13+
}
14+
15+
const sidebarWidthClasses: Record<NonNullable<SidebarLayoutProps['sidebarWidth']>, string> = {
16+
sm: 'md:w-56 lg:w-60',
17+
md: 'md:w-64 lg:w-72',
18+
lg: 'md:w-72 lg:w-80',
19+
};
20+
21+
const stickyTopClass = 'md:sticky md:top-20';
22+
23+
export default function SidebarLayout({
24+
sidebar,
25+
children,
26+
rightPanel,
27+
sidebarWidth = 'md',
28+
className,
29+
stickyBreakpoint = 'md',
30+
sidebarAriaLabel = 'サイドバー',
31+
rightPanelAriaLabel = '関連情報パネル',
32+
}: SidebarLayoutProps) {
33+
const stickyClass =
34+
stickyBreakpoint === 'md' ? stickyTopClass : 'lg:sticky lg:top-20';
35+
36+
return (
37+
<div
38+
className={cn(
39+
'flex flex-col md:flex-row gap-6 w-full',
40+
className,
41+
)}
42+
>
43+
<aside
44+
aria-label={sidebarAriaLabel}
45+
className={cn(
46+
'w-full shrink-0',
47+
sidebarWidthClasses[sidebarWidth],
48+
stickyClass,
49+
'md:self-start',
50+
)}
51+
>
52+
{sidebar}
53+
</aside>
54+
<main className="flex-1 min-w-0">{children}</main>
55+
{rightPanel && (
56+
<aside
57+
aria-label={rightPanelAriaLabel}
58+
className="w-full md:w-64 lg:w-80 shrink-0 lg:self-start lg:sticky lg:top-20"
59+
>
60+
{rightPanel}
61+
</aside>
62+
)}
63+
</div>
64+
);
65+
}

0 commit comments

Comments
 (0)