feat(ui): themed Select/DatePicker + compact heatmap + subjects mobile fix — 7G.5
This commit is contained in:
@@ -5,7 +5,8 @@ import { useRouter } from 'next/navigation';
|
||||
import styled from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { Badge, Button, Card, Label, PageHeader, Select } from '@/components/ui/primitives';
|
||||
import Select from '@/components/ui/Select';
|
||||
import { Badge, Button, Card, Label, PageHeader } from '@/components/ui/primitives';
|
||||
import { api, type ProblemSetSummary } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
@@ -76,31 +77,26 @@ function ExamsBody() {
|
||||
<Label>연도</Label>
|
||||
<FieldSelect
|
||||
value={selectedYear}
|
||||
onChange={(event) =>
|
||||
setSelectedYear(event.target.value === 'all' ? 'all' : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{YEAR_FILTERS.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
onChange={(value) => setSelectedYear(value as YearFilter)}
|
||||
options={[
|
||||
{ label: '전체', value: 'all' as const },
|
||||
...YEAR_FILTERS.map((year) => ({ label: String(year), value: year })),
|
||||
]}
|
||||
aria-label="연도 필터"
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
<FilterField>
|
||||
<Label>과목</Label>
|
||||
<FieldSelect
|
||||
value={selectedSubject}
|
||||
onChange={(event) => setSelectedSubject(event.target.value as SubjectFilter)}
|
||||
>
|
||||
{SUBJECT_FILTERS.map((subject) => (
|
||||
<option key={subject} value={subject}>
|
||||
{subject}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
onChange={(value) => setSelectedSubject(value as SubjectFilter)}
|
||||
options={SUBJECT_FILTERS.map((subject) => ({
|
||||
label: subject,
|
||||
value: subject,
|
||||
}))}
|
||||
aria-label="과목 필터"
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterRow>
|
||||
</FilterCard>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import Select from '@/components/ui/Select';
|
||||
import {
|
||||
api,
|
||||
type MasteryPathResponse,
|
||||
@@ -44,7 +45,7 @@ interface HeatmapCellData {
|
||||
dateLabel: string;
|
||||
count: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
isCurrentMonthLabel: boolean;
|
||||
isMonthLabel: boolean;
|
||||
}
|
||||
|
||||
interface TagOption {
|
||||
@@ -69,9 +70,16 @@ interface FloatingTooltip {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
interface HeatmapData {
|
||||
columns: HeatmapCellData[][];
|
||||
legend: string[];
|
||||
logsByDate: Map<string, StudyLog[]>;
|
||||
}
|
||||
|
||||
type RangeKey = '7' | '30' | '90' | 'all';
|
||||
|
||||
const FETCH_LIMIT = 200;
|
||||
const HEATMAP_WEEKS = 18;
|
||||
const RANGE_OPTIONS: Array<{ key: RangeKey; label: string }> = [
|
||||
{ key: '7', label: '7일' },
|
||||
{ key: '30', label: '30일' },
|
||||
@@ -101,6 +109,7 @@ function StatsBody() {
|
||||
const [masteryLoading, setMasteryLoading] = useState(false);
|
||||
|
||||
const [floatingTooltip, setFloatingTooltip] = useState<FloatingTooltip | null>(null);
|
||||
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -222,7 +231,7 @@ function StatsBody() {
|
||||
.sort(sortByStudiedAtAsc);
|
||||
}, [logs, range]);
|
||||
|
||||
const heatmap = useMemo(() => buildHeatmap(filteredLogs, range), [filteredLogs, range]);
|
||||
const heatmap = useMemo(() => buildHeatmap(logs), [logs]);
|
||||
const subjectAccuracy = useMemo(
|
||||
() => buildSubjectAccuracy(filteredLogs, subjects),
|
||||
[filteredLogs, subjects],
|
||||
@@ -232,6 +241,13 @@ function StatsBody() {
|
||||
() => tagOptions.find((tag) => tag.tagId === selectedTagId) ?? null,
|
||||
[selectedTagId, tagOptions],
|
||||
);
|
||||
const selectedHeatmapLogs = useMemo(
|
||||
() =>
|
||||
selectedHeatmapDate
|
||||
? [...(heatmap.logsByDate.get(selectedHeatmapDate) ?? [])].sort(sortByStudiedAtAsc)
|
||||
: [],
|
||||
[heatmap.logsByDate, selectedHeatmapDate],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <StateCard>로딩 중...</StateCard>;
|
||||
@@ -270,6 +286,7 @@ function StatsBody() {
|
||||
tooltip={floatingTooltip}
|
||||
onShowTooltip={setFloatingTooltip}
|
||||
onHideTooltip={() => setFloatingTooltip(null)}
|
||||
onSelectCell={setSelectedHeatmapDate}
|
||||
/>
|
||||
|
||||
<BottomGrid>
|
||||
@@ -288,6 +305,12 @@ function StatsBody() {
|
||||
/>
|
||||
</BottomGrid>
|
||||
</Panels>
|
||||
|
||||
<DayLogDrawer
|
||||
date={selectedHeatmapDate}
|
||||
logs={selectedHeatmapLogs}
|
||||
onClose={() => setSelectedHeatmapDate(null)}
|
||||
/>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
@@ -297,14 +320,16 @@ function HeatmapPanel({
|
||||
tooltip,
|
||||
onShowTooltip,
|
||||
onHideTooltip,
|
||||
onSelectCell,
|
||||
}: {
|
||||
data: ReturnType<typeof buildHeatmap>;
|
||||
data: HeatmapData;
|
||||
tooltip: FloatingTooltip | null;
|
||||
onShowTooltip: (tooltip: FloatingTooltip | null) => void;
|
||||
onHideTooltip: () => void;
|
||||
onSelectCell: (date: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard>
|
||||
<HeatmapCard>
|
||||
<PanelHeader>
|
||||
<PanelTitleGroup>
|
||||
<PanelTitle>
|
||||
@@ -312,21 +337,9 @@ function HeatmapPanel({
|
||||
학습 히트맵
|
||||
</PanelTitle>
|
||||
<PanelDescription>
|
||||
어느 날 네 리듬이 가장 강했는지 한눈에 보여줘.
|
||||
최근 {HEATMAP_WEEKS}주 동안 어느 날 학습 밀도가 높았는지 한눈에 보여줘.
|
||||
</PanelDescription>
|
||||
</PanelTitleGroup>
|
||||
|
||||
<Legend>
|
||||
<LegendLabel>적음</LegendLabel>
|
||||
<LegendArrow>←</LegendArrow>
|
||||
<LegendSwatches>
|
||||
{data.legend.map((color, index) => (
|
||||
<LegendSwatch key={index} $color={color} />
|
||||
))}
|
||||
</LegendSwatches>
|
||||
<LegendArrow>→</LegendArrow>
|
||||
<LegendLabel>많음</LegendLabel>
|
||||
</Legend>
|
||||
</PanelHeader>
|
||||
|
||||
{data.columns.length === 0 ? (
|
||||
@@ -344,7 +357,7 @@ function HeatmapPanel({
|
||||
<MonthRow>
|
||||
{data.columns.map((column, index) => (
|
||||
<MonthLabel key={column[0]?.key ?? index}>
|
||||
{column[0]?.isCurrentMonthLabel ? formatMonth(column[0].date) : ''}
|
||||
{column[0]?.isMonthLabel ? formatMonth(column[0].date) : ''}
|
||||
</MonthLabel>
|
||||
))}
|
||||
</MonthRow>
|
||||
@@ -362,23 +375,48 @@ function HeatmapPanel({
|
||||
onShowTooltip({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
title: cell.dateLabel,
|
||||
subtitle: `${cell.count}개 학습`,
|
||||
title: `${cell.dateLabel}: ${cell.count}개 학습`,
|
||||
})
|
||||
}
|
||||
onFocus={() =>
|
||||
onShowTooltip({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
title: `${cell.dateLabel}: ${cell.count}개 학습`,
|
||||
})
|
||||
}
|
||||
onMouseLeave={onHideTooltip}
|
||||
onBlur={onHideTooltip}
|
||||
onClick={() => onSelectCell(cell.key)}
|
||||
/>
|
||||
))}
|
||||
</WeekColumn>
|
||||
))}
|
||||
</GridColumns>
|
||||
|
||||
{tooltip ? <TooltipBubble $x={tooltip.x} $y={tooltip.y}>{tooltip.title}<TooltipSub>{tooltip.subtitle}</TooltipSub></TooltipBubble> : null}
|
||||
{tooltip ? (
|
||||
<TooltipBubble $x={tooltip.x} $y={tooltip.y}>
|
||||
{tooltip.title}
|
||||
{tooltip.subtitle ? <TooltipSub>{tooltip.subtitle}</TooltipSub> : null}
|
||||
</TooltipBubble>
|
||||
) : null}
|
||||
</HeatmapChart>
|
||||
</HeatmapShell>
|
||||
</HeatmapScroll>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
<Legend>
|
||||
<LegendLabel>적음</LegendLabel>
|
||||
<LegendArrow>←</LegendArrow>
|
||||
<LegendSwatches>
|
||||
{data.legend.map((color, index) => (
|
||||
<LegendSwatch key={index} $color={color} />
|
||||
))}
|
||||
</LegendSwatches>
|
||||
<LegendArrow>→</LegendArrow>
|
||||
<LegendLabel>많음</LegendLabel>
|
||||
</Legend>
|
||||
</HeatmapCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,24 +502,16 @@ function MasteryGrowthPanel({
|
||||
|
||||
<SelectWrap>
|
||||
<TagSelect
|
||||
value={selectedTagId ?? ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
onSelectTag(nextValue ? Number(nextValue) : null);
|
||||
}}
|
||||
value={selectedTagId}
|
||||
onChange={(value) => onSelectTag(value as number)}
|
||||
options={options.map((option) => ({
|
||||
label: `${option.subjectName} · ${option.name}`,
|
||||
value: option.tagId,
|
||||
}))}
|
||||
placeholder="태그 없음"
|
||||
disabled={options.length === 0}
|
||||
aria-label="마스터리 곡선 태그 선택"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<option value="">태그 없음</option>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<option key={option.tagId} value={option.tagId}>
|
||||
{option.subjectName} · {option.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</TagSelect>
|
||||
/>
|
||||
</SelectWrap>
|
||||
</PanelHeader>
|
||||
|
||||
@@ -512,6 +542,60 @@ function MasteryGrowthPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function DayLogDrawer({
|
||||
date,
|
||||
logs,
|
||||
onClose,
|
||||
}: {
|
||||
date: string | null;
|
||||
logs: StudyLog[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!date) return null;
|
||||
|
||||
return (
|
||||
<DrawerOverlay onClick={onClose} role="presentation">
|
||||
<DrawerPanel
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="stats-day-drawer-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DrawerHeader>
|
||||
<div>
|
||||
<DrawerTitle id="stats-day-drawer-title">{date}</DrawerTitle>
|
||||
<DrawerSubtitle>{logs.length}개 학습 기록</DrawerSubtitle>
|
||||
</div>
|
||||
<DrawerCloseButton type="button" onClick={onClose} aria-label="닫기">
|
||||
<Icon name="x" size={18} />
|
||||
</DrawerCloseButton>
|
||||
</DrawerHeader>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<DrawerEmpty>이 날에는 저장된 학습 기록이 없어.</DrawerEmpty>
|
||||
) : (
|
||||
<DrawerList>
|
||||
{logs.map((log) => (
|
||||
<DrawerItem key={log.id}>
|
||||
<DrawerItemTop>
|
||||
<DrawerItemTitle>{log.title}</DrawerItemTitle>
|
||||
<DrawerResultChip $result={log.result}>
|
||||
{resultLabel(log.result)}
|
||||
</DrawerResultChip>
|
||||
</DrawerItemTop>
|
||||
<DrawerMeta>
|
||||
<span>{log.subject?.name ?? '과목 미지정'}</span>
|
||||
<span>{formatTime(log.studiedAt)}</span>
|
||||
</DrawerMeta>
|
||||
</DrawerItem>
|
||||
))}
|
||||
</DrawerList>
|
||||
)}
|
||||
</DrawerPanel>
|
||||
</DrawerOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
function CurveChart({
|
||||
points,
|
||||
tooltip,
|
||||
@@ -683,43 +767,38 @@ function normalizeStudyLogResponse(payload: StudyLog[] | StudyLogListResponse):
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildHeatmap(logs: StudyLog[], range: RangeKey) {
|
||||
const countByDate = new Map<string, number>();
|
||||
function buildHeatmap(logs: StudyLog[]): HeatmapData {
|
||||
const logsByDate = new Map<string, StudyLog[]>();
|
||||
|
||||
for (const log of logs) {
|
||||
const key = toDateKey(log.studiedAt);
|
||||
countByDate.set(key, (countByDate.get(key) ?? 0) + 1);
|
||||
logsByDate.set(key, [...(logsByDate.get(key) ?? []), log]);
|
||||
}
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const earliestDate = logs.length > 0 ? startOfDay(new Date(logs[0].studiedAt)) : today;
|
||||
const startBase =
|
||||
range === 'all'
|
||||
? earliestDate
|
||||
: (() => {
|
||||
const start = startOfDay(new Date());
|
||||
start.setDate(start.getDate() - (Number(range) - 1));
|
||||
return start;
|
||||
})();
|
||||
|
||||
const start = startOfWeek(startBase);
|
||||
const end = endOfWeek(today);
|
||||
const start = startOfWeek(addDays(end, -(HEATMAP_WEEKS * 7 - 7)));
|
||||
const days = eachDayBetween(start, end);
|
||||
const maxCount = Math.max(...Array.from(countByDate.values()), 0);
|
||||
const counts = Array.from(logsByDate.values()).map((items) => items.length);
|
||||
const maxCount = Math.max(...counts, 0);
|
||||
const columns: HeatmapCellData[][] = [];
|
||||
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
const week = days.slice(index, index + 7).map((date, dayIndex) => {
|
||||
const weekStart = days[index];
|
||||
const previousWeekStart = index === 0 ? null : days[index - 7];
|
||||
const week = days.slice(index, index + 7).map((date) => {
|
||||
const key = toDateKey(date.toISOString());
|
||||
const count = countByDate.get(key) ?? 0;
|
||||
const count = logsByDate.get(key)?.length ?? 0;
|
||||
|
||||
return {
|
||||
key,
|
||||
date,
|
||||
dateLabel: formatFullDate(date),
|
||||
dateLabel: formatIsoDate(date),
|
||||
count,
|
||||
level: resolveHeatLevel(count, maxCount),
|
||||
isCurrentMonthLabel: dayIndex === 0 && (index === 0 || date.getMonth() !== days[index - 1].getMonth()),
|
||||
isMonthLabel:
|
||||
date.getDay() === 1 &&
|
||||
(previousWeekStart === null || date.getMonth() !== previousWeekStart.getMonth()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -728,12 +807,13 @@ function buildHeatmap(logs: StudyLog[], range: RangeKey) {
|
||||
|
||||
return {
|
||||
columns,
|
||||
logsByDate,
|
||||
legend: [
|
||||
theme.color.surfaceHoverDeep,
|
||||
'rgba(79, 70, 229, 0.18)',
|
||||
'rgba(79, 70, 229, 0.36)',
|
||||
'rgba(79, 70, 229, 0.62)',
|
||||
theme.color.brandIndigo,
|
||||
'#7C83FF',
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -860,6 +940,12 @@ function eachDayBetween(start: Date, end: Date): Date[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function addDays(value: Date, amount: number): Date {
|
||||
const date = new Date(value);
|
||||
date.setDate(date.getDate() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function toDateKey(value: string): string {
|
||||
const date = new Date(value);
|
||||
const year = date.getFullYear();
|
||||
@@ -872,11 +958,11 @@ function formatMonth(value: Date): string {
|
||||
return `${value.getMonth() + 1}월`;
|
||||
}
|
||||
|
||||
function formatFullDate(value: Date): string {
|
||||
function formatIsoDate(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
return `${year}.${month}.${day}`;
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatShortDate(value: string): string {
|
||||
@@ -886,6 +972,19 @@ function formatShortDate(value: string): string {
|
||||
return `${month}.${day}`;
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function resultLabel(result: StudyLog['result']) {
|
||||
if (result === 'correct') return '맞음';
|
||||
if (result === 'partial') return '부분';
|
||||
return '틀림';
|
||||
}
|
||||
|
||||
const PageWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -994,6 +1093,12 @@ const PanelCard = styled.section`
|
||||
}
|
||||
`;
|
||||
|
||||
const HeatmapCard = styled(PanelCard)`
|
||||
width: min(100%, 720px);
|
||||
justify-self: center;
|
||||
margin-inline: auto;
|
||||
`;
|
||||
|
||||
const PanelHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1061,24 +1166,24 @@ const LegendSwatch = styled.span<{ $color: string }>`
|
||||
|
||||
const HeatmapScroll = styled.div`
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const HeatmapShell = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(560px, 1fr);
|
||||
grid-template-columns: 22px max-content;
|
||||
gap: ${theme.space.sm};
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
min-width: 620px;
|
||||
min-width: 520px;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeatmapAxis = styled.div`
|
||||
display: grid;
|
||||
grid-template-rows: 16px repeat(7, 16px);
|
||||
row-gap: 8px;
|
||||
grid-template-rows: 14px repeat(7, 15px);
|
||||
row-gap: 5px;
|
||||
padding-top: 6px;
|
||||
`;
|
||||
|
||||
@@ -1093,15 +1198,15 @@ const AxisLabel = styled.span`
|
||||
const HeatmapChart = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const MonthRow = styled.div`
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 16px;
|
||||
column-gap: 8px;
|
||||
min-height: 16px;
|
||||
grid-auto-columns: 15px;
|
||||
column-gap: 5px;
|
||||
min-height: 14px;
|
||||
`;
|
||||
|
||||
const MonthLabel = styled.span`
|
||||
@@ -1113,19 +1218,19 @@ const MonthLabel = styled.span`
|
||||
const GridColumns = styled.div`
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 16px;
|
||||
column-gap: 8px;
|
||||
grid-auto-columns: 15px;
|
||||
column-gap: 5px;
|
||||
`;
|
||||
|
||||
const WeekColumn = styled.div`
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 16px);
|
||||
row-gap: 8px;
|
||||
grid-template-rows: repeat(7, 15px);
|
||||
row-gap: 5px;
|
||||
`;
|
||||
|
||||
const HeatCell = styled.button<{ $level: 0 | 1 | 2 | 3 | 4 }>`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 1px solid
|
||||
${({ $level }) =>
|
||||
$level === 0 ? theme.color.borderSoftAlpha : 'transparent'};
|
||||
@@ -1217,18 +1322,11 @@ const AccuracyMeta = styled.div`
|
||||
`;
|
||||
|
||||
const SelectWrap = styled.div`
|
||||
min-width: 180px;
|
||||
min-width: 220px;
|
||||
`;
|
||||
|
||||
const TagSelect = styled.select`
|
||||
const TagSelect = styled(Select)`
|
||||
width: 100%;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 12px;
|
||||
background: ${theme.color.surfaceHoverDeep};
|
||||
color: ${theme.color.textBright};
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const TagMeta = styled.div`
|
||||
@@ -1292,6 +1390,147 @@ const EmptyPanel = styled.div`
|
||||
line-height: 1.7;
|
||||
`;
|
||||
|
||||
const DrawerOverlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(11, 16, 32, 0.58);
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
align-items: flex-end;
|
||||
}
|
||||
`;
|
||||
|
||||
const DrawerPanel = styled.div`
|
||||
width: min(420px, 100vw);
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
border-left: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.14), transparent 34%),
|
||||
rgba(21, 21, 28, 0.98);
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
height: auto;
|
||||
max-height: 78vh;
|
||||
border-left: 0;
|
||||
border-top: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const DrawerHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const DrawerTitle = styled.h3`
|
||||
margin: 0;
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const DrawerSubtitle = styled.p`
|
||||
margin: 6px 0 0;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const DrawerCloseButton = styled.button`
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const DrawerEmpty = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed ${theme.color.borderSoftAlpha};
|
||||
color: ${theme.color.textSub};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const DrawerList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const DrawerItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
`;
|
||||
|
||||
const DrawerItemTop = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const DrawerItemTitle = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
`;
|
||||
|
||||
const DrawerMeta = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const DrawerResultChip = styled.span<{ $result: StudyLog['result'] }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: ${({ $result }) =>
|
||||
$result === 'correct'
|
||||
? 'rgba(34, 197, 94, 0.14)'
|
||||
: $result === 'partial'
|
||||
? 'rgba(245, 158, 11, 0.14)'
|
||||
: 'rgba(239, 68, 68, 0.14)'};
|
||||
color: ${({ $result }) =>
|
||||
$result === 'correct'
|
||||
? theme.color.success
|
||||
: $result === 'partial'
|
||||
? theme.color.warning
|
||||
: theme.color.danger};
|
||||
`;
|
||||
|
||||
const StateCard = styled.section`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,7 +4,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import Select from '@/components/ui/Select';
|
||||
import { api, type StudyLog, type StudyResult, type Subject } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
@@ -191,35 +193,35 @@ function HistoryBody() {
|
||||
|
||||
<FilterSelect
|
||||
value={subjectFilter}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setSubjectFilter(value === 'all' ? 'all' : Number(value));
|
||||
}}
|
||||
>
|
||||
<option value="all">전체 과목</option>
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
onChange={(value) => setSubjectFilter(value as number | 'all')}
|
||||
options={[
|
||||
{ label: '전체 과목', value: 'all' as const },
|
||||
...subjects.map((subject) => ({
|
||||
label: subject.name,
|
||||
value: subject.id,
|
||||
})),
|
||||
]}
|
||||
aria-label="과목 필터"
|
||||
/>
|
||||
|
||||
<DateRangeGroup>
|
||||
<DateInput
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
setStartDate(event.target.value);
|
||||
value={startDate || null}
|
||||
max={endDate || undefined}
|
||||
placeholder="시작 날짜"
|
||||
onChange={(nextValue) => {
|
||||
setStartDate(nextValue);
|
||||
setQuickRange('custom');
|
||||
}}
|
||||
aria-label="시작 날짜"
|
||||
/>
|
||||
<DateDash>~</DateDash>
|
||||
<DateInput
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => {
|
||||
setEndDate(event.target.value);
|
||||
value={endDate || null}
|
||||
min={startDate || undefined}
|
||||
placeholder="종료 날짜"
|
||||
onChange={(nextValue) => {
|
||||
setEndDate(nextValue);
|
||||
setQuickRange('custom');
|
||||
}}
|
||||
aria-label="종료 날짜"
|
||||
@@ -731,10 +733,8 @@ const SearchInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterSelect = styled.select`
|
||||
const FilterSelect = styled(Select)<{ }>`
|
||||
${inputShared};
|
||||
padding: 0 14px;
|
||||
appearance: none;
|
||||
`;
|
||||
|
||||
const DateRangeGroup = styled.div`
|
||||
@@ -748,10 +748,8 @@ const DateRangeGroup = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const DateInput = styled.input`
|
||||
const DateInput = styled(DatePicker)`
|
||||
${inputShared};
|
||||
padding: 0 14px;
|
||||
color-scheme: dark;
|
||||
`;
|
||||
|
||||
const DateDash = styled.span`
|
||||
|
||||
@@ -919,13 +919,20 @@ const TabsRow = styled.div`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scroll-snap-type: x mandatory;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -934,13 +941,16 @@ const TabButton = styled.button<{ $active: boolean }>`
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 0 16px;
|
||||
padding: 10px 16px;
|
||||
flex-shrink: 0;
|
||||
scroll-snap-align: start;
|
||||
border-radius: ${theme.radius.pill};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(21, 21, 28, 0.72);
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
547
frontend/src/components/ui/DatePicker.tsx
Normal file
547
frontend/src/components/ui/DatePicker.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export interface DatePickerProps {
|
||||
className?: string;
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
type PopoverPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
const EMPTY_POSITION: PopoverPosition = { top: 0, left: 0, width: 0 };
|
||||
|
||||
export default function DatePicker({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
placeholder = '날짜 선택',
|
||||
disabled = false,
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
}: DatePickerProps) {
|
||||
const reactId = useId();
|
||||
const dialogId = id ?? `date-picker-${reactId}`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const dayRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const selectedDate = value ? parseIsoDate(value) : null;
|
||||
const minDate = min ? parseIsoDate(min) : null;
|
||||
const maxDate = max ? parseIsoDate(max) : null;
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMonth, setViewMonth] = useState<Date>(
|
||||
selectedDate ? startOfMonth(selectedDate) : startOfMonth(today),
|
||||
);
|
||||
const [position, setPosition] = useState<PopoverPosition>(EMPTY_POSITION);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setViewMonth(selectedDate ? startOfMonth(selectedDate) : startOfMonth(today));
|
||||
}, [open, selectedDate, today]);
|
||||
|
||||
const days = useMemo(() => buildCalendarDays(viewMonth), [viewMonth]);
|
||||
|
||||
const updatePosition = React.useCallback(() => {
|
||||
const anchor = buttonRef.current;
|
||||
if (!anchor) return;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 304);
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const estimatedHeight = 364;
|
||||
const left = Math.min(
|
||||
Math.max(12, rect.left),
|
||||
Math.max(12, viewportWidth - width - 12),
|
||||
);
|
||||
const openUpward =
|
||||
rect.bottom + 8 + estimatedHeight > viewportHeight - 12 &&
|
||||
rect.top - 8 - estimatedHeight > 12;
|
||||
|
||||
setPosition({
|
||||
top: openUpward ? Math.max(12, rect.top - 8 - estimatedHeight) : rect.bottom + 8,
|
||||
left,
|
||||
width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleWindowChange = () => updatePosition();
|
||||
window.addEventListener('resize', handleWindowChange);
|
||||
window.addEventListener('scroll', handleWindowChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowChange);
|
||||
window.removeEventListener('scroll', handleWindowChange, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (
|
||||
target &&
|
||||
!rootRef.current?.contains(target) &&
|
||||
!popoverRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('touchstart', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('touchstart', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const preferredKey =
|
||||
(selectedDate && isSameMonth(selectedDate, viewMonth) && formatIsoDate(selectedDate)) ||
|
||||
(isSameMonth(today, viewMonth) && formatIsoDate(today)) ||
|
||||
days.find((day) => day.inCurrentMonth && !isDisabled(day.date, minDate, maxDate))?.key;
|
||||
|
||||
if (!preferredKey) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
dayRefs.current[preferredKey]?.focus();
|
||||
});
|
||||
}, [days, maxDate, minDate, open, selectedDate, today, viewMonth]);
|
||||
|
||||
const monthLabel = `${viewMonth.getFullYear()}년 ${viewMonth.getMonth() + 1}월`;
|
||||
|
||||
const selectDay = (date: Date) => {
|
||||
onChange(formatIsoDate(date));
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleDialogKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const container = popoverRef.current;
|
||||
if (!container) return;
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('button:not(:disabled)'),
|
||||
);
|
||||
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Root ref={rootRef} className={className}>
|
||||
<Trigger
|
||||
ref={buttonRef}
|
||||
id={dialogId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
$open={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={`${dialogId}-dialog`}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<TriggerLeft>
|
||||
<Icon name="calendar-blank" size={16} />
|
||||
<TriggerLabel $placeholder={!value}>{value ?? placeholder}</TriggerLabel>
|
||||
</TriggerLeft>
|
||||
<CaretWrap $open={open}>
|
||||
<Icon name="caret-down" size={16} weight="bold" />
|
||||
</CaretWrap>
|
||||
</Trigger>
|
||||
</Root>
|
||||
|
||||
{open &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<Popover
|
||||
ref={popoverRef}
|
||||
id={`${dialogId}-dialog`}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={`${dialogId}-label`}
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`,
|
||||
}}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
<CalendarCard>
|
||||
<CalendarHeader>
|
||||
<NavButton
|
||||
type="button"
|
||||
onClick={() => setViewMonth((current) => addMonths(current, -1))}
|
||||
aria-label="이전 달"
|
||||
>
|
||||
<Icon name="caret-left" size={16} weight="bold" />
|
||||
</NavButton>
|
||||
<MonthLabel id={`${dialogId}-label`}>{monthLabel}</MonthLabel>
|
||||
<NavButton
|
||||
type="button"
|
||||
onClick={() => setViewMonth((current) => addMonths(current, 1))}
|
||||
aria-label="다음 달"
|
||||
>
|
||||
<Icon name="caret-right" size={16} weight="bold" />
|
||||
</NavButton>
|
||||
</CalendarHeader>
|
||||
|
||||
<WeekdayRow>
|
||||
{WEEKDAY_LABELS.map((day) => (
|
||||
<Weekday key={day}>{day}</Weekday>
|
||||
))}
|
||||
</WeekdayRow>
|
||||
|
||||
<DaysGrid>
|
||||
{days.map((day) => {
|
||||
const iso = formatIsoDate(day.date);
|
||||
const disabledDay = isDisabled(day.date, minDate, maxDate);
|
||||
const selected = selectedDate ? isSameDay(day.date, selectedDate) : false;
|
||||
const isToday = isSameDay(day.date, today);
|
||||
|
||||
return (
|
||||
<DayButton
|
||||
key={day.key}
|
||||
ref={(node) => {
|
||||
dayRefs.current[iso] = node;
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabledDay}
|
||||
$outside={!day.inCurrentMonth}
|
||||
$selected={selected}
|
||||
$today={isToday}
|
||||
onClick={() => selectDay(day.date)}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</DayButton>
|
||||
);
|
||||
})}
|
||||
</DaysGrid>
|
||||
</CalendarCard>
|
||||
</Popover>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCalendarDays(month: Date) {
|
||||
const monthStart = startOfMonth(month);
|
||||
const gridStart = startOfWeek(monthStart);
|
||||
const result: Array<{ key: string; date: Date; inCurrentMonth: boolean }> = [];
|
||||
|
||||
for (let index = 0; index < 42; index += 1) {
|
||||
const date = addDays(gridStart, index);
|
||||
result.push({
|
||||
key: `${formatIsoDate(date)}-${index}`,
|
||||
date,
|
||||
inCurrentMonth: date.getMonth() === monthStart.getMonth(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseIsoDate(value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function formatIsoDate(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function startOfMonth(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), 1);
|
||||
}
|
||||
|
||||
function startOfWeek(value: Date) {
|
||||
const date = startOfDay(value);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
date.setDate(date.getDate() + diff);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addMonths(value: Date, amount: number) {
|
||||
return new Date(value.getFullYear(), value.getMonth() + amount, 1);
|
||||
}
|
||||
|
||||
function addDays(value: Date, amount: number) {
|
||||
const date = new Date(value);
|
||||
date.setDate(date.getDate() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function isSameMonth(a: Date, b: Date) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
||||
}
|
||||
|
||||
function isDisabled(value: Date, min: Date | null, max: Date | null) {
|
||||
const time = startOfDay(value).getTime();
|
||||
if (min && time < startOfDay(min).getTime()) return true;
|
||||
if (max && time > startOfDay(max).getTime()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const Root = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Trigger = styled.button<{ $open: boolean }>`
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid
|
||||
${({ $open }) =>
|
||||
$open ? 'rgba(129, 140, 248, 0.55)' : theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textMain};
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(129, 140, 248, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.16);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const TriggerLeft = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
color: ${theme.color.textSub};
|
||||
`;
|
||||
|
||||
const TriggerLabel = styled.span<{ $placeholder: boolean }>`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: ${({ $placeholder }) =>
|
||||
$placeholder ? theme.color.textMute : theme.color.textMain};
|
||||
`;
|
||||
|
||||
const CaretWrap = styled.span<{ $open: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: ${theme.color.textSub};
|
||||
transition: transform 0.18s ease;
|
||||
transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
`;
|
||||
|
||||
const Popover = styled.div`
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
`;
|
||||
|
||||
const CalendarCard = styled.div`
|
||||
padding: 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid ${theme.color.borderBrightAlpha};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.14), transparent 34%),
|
||||
rgba(21, 21, 28, 0.98);
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
backdrop-filter: blur(16px);
|
||||
`;
|
||||
|
||||
const CalendarHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const MonthLabel = styled.div`
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const NavButton = styled.button`
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: ${theme.color.textSub};
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: ${theme.color.borderBrightAlpha};
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
`;
|
||||
|
||||
const WeekdayRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Weekday = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const DaysGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const DayButton = styled.button<{
|
||||
$outside: boolean;
|
||||
$selected: boolean;
|
||||
$today: boolean;
|
||||
}>`
|
||||
min-height: 38px;
|
||||
border: 1px solid
|
||||
${({ $selected, $today }) =>
|
||||
$selected
|
||||
? 'rgba(129, 140, 248, 0.68)'
|
||||
: $today
|
||||
? 'rgba(129, 140, 248, 0.28)'
|
||||
: 'transparent'};
|
||||
border-radius: 12px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.26)' : 'transparent'};
|
||||
color: ${({ $outside, $selected }) =>
|
||||
$selected
|
||||
? theme.color.textBright
|
||||
: $outside
|
||||
? theme.color.textMute
|
||||
: theme.color.textMain};
|
||||
font-size: 13px;
|
||||
font-weight: ${({ $selected, $today }) => ($selected || $today ? 700 : 500)};
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.3)' : 'rgba(255, 255, 255, 0.06)'};
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
451
frontend/src/components/ui/Select.tsx
Normal file
451
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
export interface SelectOption<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface SelectProps<T> {
|
||||
className?: string;
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<SelectOption<T>>;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
type PopoverPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const EMPTY_POSITION: PopoverPosition = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
};
|
||||
|
||||
export default function Select<T>({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = '선택',
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
}: SelectProps<T>) {
|
||||
const reactId = useId();
|
||||
const listboxId = id ?? `select-${reactId}`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [position, setPosition] = useState<PopoverPosition>(EMPTY_POSITION);
|
||||
|
||||
const selectedIndex = useMemo(
|
||||
() => options.findIndex((option) => Object.is(option.value, value)),
|
||||
[options, value],
|
||||
);
|
||||
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : null;
|
||||
|
||||
const updatePosition = React.useCallback(() => {
|
||||
const anchor = buttonRef.current;
|
||||
if (!anchor) return;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.max(rect.width, 180);
|
||||
const left = Math.min(
|
||||
Math.max(12, rect.left),
|
||||
Math.max(12, viewportWidth - width - 12),
|
||||
);
|
||||
const estimatedHeight = Math.min(320, options.length * 40 + 24);
|
||||
const openUpward =
|
||||
rect.bottom + 8 + estimatedHeight > viewportHeight - 12 &&
|
||||
rect.top - 8 - estimatedHeight > 12;
|
||||
|
||||
setPosition({
|
||||
top: openUpward ? Math.max(12, rect.top - 8 - estimatedHeight) : rect.bottom + 8,
|
||||
left,
|
||||
width,
|
||||
});
|
||||
}, [options.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleWindowChange = () => updatePosition();
|
||||
window.addEventListener('resize', handleWindowChange);
|
||||
window.addEventListener('scroll', handleWindowChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowChange);
|
||||
window.removeEventListener('scroll', handleWindowChange, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const nextIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
setActiveIndex(nextIndex);
|
||||
}, [open, selectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (
|
||||
target &&
|
||||
!rootRef.current?.contains(target) &&
|
||||
!popoverRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('touchstart', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('touchstart', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeIndex < 0) return;
|
||||
optionRefs.current[activeIndex]?.focus();
|
||||
optionRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const openMenu = (nextIndex?: number) => {
|
||||
if (disabled || options.length === 0) return;
|
||||
setOpen(true);
|
||||
setActiveIndex(nextIndex ?? (selectedIndex >= 0 ? selectedIndex : 0));
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const selectIndex = (index: number) => {
|
||||
const nextOption = options[index];
|
||||
if (!nextOption) return;
|
||||
onChange(nextOption.value);
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const moveActive = (direction: 1 | -1) => {
|
||||
if (options.length === 0) return;
|
||||
setActiveIndex((current) => {
|
||||
if (current < 0) return direction === 1 ? 0 : options.length - 1;
|
||||
const next = current + direction;
|
||||
if (next < 0) return options.length - 1;
|
||||
if (next >= options.length) return 0;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleButtonKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (!open) openMenu(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
else moveActive(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!open) openMenu(selectedIndex >= 0 ? selectedIndex : options.length - 1);
|
||||
else moveActive(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
openMenu();
|
||||
} else if (activeIndex >= 0) {
|
||||
selectIndex(activeIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopoverKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
moveActive(event.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveActive(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActive(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(options.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) {
|
||||
selectIndex(activeIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Root ref={rootRef} className={className}>
|
||||
<Trigger
|
||||
ref={buttonRef}
|
||||
id={listboxId}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={`${listboxId}-listbox`}
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
$size={size}
|
||||
$open={open}
|
||||
onClick={() => (open ? closeMenu() : openMenu())}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
>
|
||||
<TriggerLabel $placeholder={selectedOption === null}>
|
||||
{selectedOption?.label ?? placeholder}
|
||||
</TriggerLabel>
|
||||
<CaretWrap $open={open}>
|
||||
<Icon name="caret-down" size={16} weight="bold" />
|
||||
</CaretWrap>
|
||||
</Trigger>
|
||||
</Root>
|
||||
|
||||
{open &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<Popover
|
||||
ref={popoverRef}
|
||||
role="presentation"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`,
|
||||
}}
|
||||
>
|
||||
<OptionList
|
||||
id={`${listboxId}-listbox`}
|
||||
role="listbox"
|
||||
aria-labelledby={listboxId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handlePopoverKeyDown}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
const active = index === activeIndex;
|
||||
|
||||
return (
|
||||
<OptionButton
|
||||
key={`${option.label}-${index}`}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
$selected={selected}
|
||||
$active={active}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => selectIndex(index)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{selected ? <Icon name="check" size={14} weight="bold" /> : null}
|
||||
</OptionButton>
|
||||
);
|
||||
})}
|
||||
</OptionList>
|
||||
</Popover>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Root = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const sizeStyles = {
|
||||
sm: css`
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
`,
|
||||
md: css`
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
`,
|
||||
lg: css`
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
`,
|
||||
} as const;
|
||||
|
||||
const Trigger = styled.button<{ $size: 'sm' | 'md' | 'lg'; $open: boolean }>`
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid
|
||||
${({ $open }) =>
|
||||
$open ? 'rgba(129, 140, 248, 0.55)' : theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textMain};
|
||||
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
text-align: left;
|
||||
|
||||
${({ $size }) => sizeStyles[$size]}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(129, 140, 248, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.16);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const TriggerLabel = styled.span<{ $placeholder: boolean }>`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: ${({ $placeholder }) =>
|
||||
$placeholder ? theme.color.textMute : theme.color.textMain};
|
||||
`;
|
||||
|
||||
const CaretWrap = styled.span<{ $open: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: ${theme.color.textSub};
|
||||
transition: transform 0.18s ease;
|
||||
transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
`;
|
||||
|
||||
const Popover = styled.div`
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
`;
|
||||
|
||||
const OptionList = styled.div`
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid ${theme.color.borderBrightAlpha};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.14), transparent 34%),
|
||||
rgba(21, 21, 28, 0.98);
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
backdrop-filter: blur(16px);
|
||||
`;
|
||||
|
||||
const OptionButton = styled.button<{ $selected: boolean; $active: boolean }>`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: ${({ $selected, $active }) =>
|
||||
$selected
|
||||
? 'rgba(79, 70, 229, 0.22)'
|
||||
: $active
|
||||
? 'rgba(255, 255, 255, 0.06)'
|
||||
: 'transparent'};
|
||||
color: ${({ $selected }) =>
|
||||
$selected ? theme.color.textBright : theme.color.textSub};
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.24)' : 'rgba(255, 255, 255, 0.07)'};
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user