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 styled from 'styled-components';
|
||||||
import AppShell from '@/components/layout/AppShell';
|
import AppShell from '@/components/layout/AppShell';
|
||||||
import { Icon } from '@/components/ui/Icon';
|
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 { api, type ProblemSetSummary } from '@/lib/api';
|
||||||
import { theme } from '@/styles/theme';
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
@@ -76,31 +77,26 @@ function ExamsBody() {
|
|||||||
<Label>연도</Label>
|
<Label>연도</Label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={selectedYear}
|
value={selectedYear}
|
||||||
onChange={(event) =>
|
onChange={(value) => setSelectedYear(value as YearFilter)}
|
||||||
setSelectedYear(event.target.value === 'all' ? 'all' : Number(event.target.value))
|
options={[
|
||||||
}
|
{ label: '전체', value: 'all' as const },
|
||||||
>
|
...YEAR_FILTERS.map((year) => ({ label: String(year), value: year })),
|
||||||
<option value="all">전체</option>
|
]}
|
||||||
{YEAR_FILTERS.map((year) => (
|
aria-label="연도 필터"
|
||||||
<option key={year} value={year}>
|
/>
|
||||||
{year}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</FieldSelect>
|
|
||||||
</FilterField>
|
</FilterField>
|
||||||
|
|
||||||
<FilterField>
|
<FilterField>
|
||||||
<Label>과목</Label>
|
<Label>과목</Label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={selectedSubject}
|
value={selectedSubject}
|
||||||
onChange={(event) => setSelectedSubject(event.target.value as SubjectFilter)}
|
onChange={(value) => setSelectedSubject(value as SubjectFilter)}
|
||||||
>
|
options={SUBJECT_FILTERS.map((subject) => ({
|
||||||
{SUBJECT_FILTERS.map((subject) => (
|
label: subject,
|
||||||
<option key={subject} value={subject}>
|
value: subject,
|
||||||
{subject}
|
}))}
|
||||||
</option>
|
aria-label="과목 필터"
|
||||||
))}
|
/>
|
||||||
</FieldSelect>
|
|
||||||
</FilterField>
|
</FilterField>
|
||||||
</FilterRow>
|
</FilterRow>
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import AppShell from '@/components/layout/AppShell';
|
import AppShell from '@/components/layout/AppShell';
|
||||||
import { Icon } from '@/components/ui/Icon';
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
import Select from '@/components/ui/Select';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type MasteryPathResponse,
|
type MasteryPathResponse,
|
||||||
@@ -44,7 +45,7 @@ interface HeatmapCellData {
|
|||||||
dateLabel: string;
|
dateLabel: string;
|
||||||
count: number;
|
count: number;
|
||||||
level: 0 | 1 | 2 | 3 | 4;
|
level: 0 | 1 | 2 | 3 | 4;
|
||||||
isCurrentMonthLabel: boolean;
|
isMonthLabel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagOption {
|
interface TagOption {
|
||||||
@@ -69,9 +70,16 @@ interface FloatingTooltip {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HeatmapData {
|
||||||
|
columns: HeatmapCellData[][];
|
||||||
|
legend: string[];
|
||||||
|
logsByDate: Map<string, StudyLog[]>;
|
||||||
|
}
|
||||||
|
|
||||||
type RangeKey = '7' | '30' | '90' | 'all';
|
type RangeKey = '7' | '30' | '90' | 'all';
|
||||||
|
|
||||||
const FETCH_LIMIT = 200;
|
const FETCH_LIMIT = 200;
|
||||||
|
const HEATMAP_WEEKS = 18;
|
||||||
const RANGE_OPTIONS: Array<{ key: RangeKey; label: string }> = [
|
const RANGE_OPTIONS: Array<{ key: RangeKey; label: string }> = [
|
||||||
{ key: '7', label: '7일' },
|
{ key: '7', label: '7일' },
|
||||||
{ key: '30', label: '30일' },
|
{ key: '30', label: '30일' },
|
||||||
@@ -101,6 +109,7 @@ function StatsBody() {
|
|||||||
const [masteryLoading, setMasteryLoading] = useState(false);
|
const [masteryLoading, setMasteryLoading] = useState(false);
|
||||||
|
|
||||||
const [floatingTooltip, setFloatingTooltip] = useState<FloatingTooltip | null>(null);
|
const [floatingTooltip, setFloatingTooltip] = useState<FloatingTooltip | null>(null);
|
||||||
|
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -222,7 +231,7 @@ function StatsBody() {
|
|||||||
.sort(sortByStudiedAtAsc);
|
.sort(sortByStudiedAtAsc);
|
||||||
}, [logs, range]);
|
}, [logs, range]);
|
||||||
|
|
||||||
const heatmap = useMemo(() => buildHeatmap(filteredLogs, range), [filteredLogs, range]);
|
const heatmap = useMemo(() => buildHeatmap(logs), [logs]);
|
||||||
const subjectAccuracy = useMemo(
|
const subjectAccuracy = useMemo(
|
||||||
() => buildSubjectAccuracy(filteredLogs, subjects),
|
() => buildSubjectAccuracy(filteredLogs, subjects),
|
||||||
[filteredLogs, subjects],
|
[filteredLogs, subjects],
|
||||||
@@ -232,6 +241,13 @@ function StatsBody() {
|
|||||||
() => tagOptions.find((tag) => tag.tagId === selectedTagId) ?? null,
|
() => tagOptions.find((tag) => tag.tagId === selectedTagId) ?? null,
|
||||||
[selectedTagId, tagOptions],
|
[selectedTagId, tagOptions],
|
||||||
);
|
);
|
||||||
|
const selectedHeatmapLogs = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedHeatmapDate
|
||||||
|
? [...(heatmap.logsByDate.get(selectedHeatmapDate) ?? [])].sort(sortByStudiedAtAsc)
|
||||||
|
: [],
|
||||||
|
[heatmap.logsByDate, selectedHeatmapDate],
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <StateCard>로딩 중...</StateCard>;
|
return <StateCard>로딩 중...</StateCard>;
|
||||||
@@ -270,6 +286,7 @@ function StatsBody() {
|
|||||||
tooltip={floatingTooltip}
|
tooltip={floatingTooltip}
|
||||||
onShowTooltip={setFloatingTooltip}
|
onShowTooltip={setFloatingTooltip}
|
||||||
onHideTooltip={() => setFloatingTooltip(null)}
|
onHideTooltip={() => setFloatingTooltip(null)}
|
||||||
|
onSelectCell={setSelectedHeatmapDate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BottomGrid>
|
<BottomGrid>
|
||||||
@@ -288,6 +305,12 @@ function StatsBody() {
|
|||||||
/>
|
/>
|
||||||
</BottomGrid>
|
</BottomGrid>
|
||||||
</Panels>
|
</Panels>
|
||||||
|
|
||||||
|
<DayLogDrawer
|
||||||
|
date={selectedHeatmapDate}
|
||||||
|
logs={selectedHeatmapLogs}
|
||||||
|
onClose={() => setSelectedHeatmapDate(null)}
|
||||||
|
/>
|
||||||
</PageWrap>
|
</PageWrap>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -297,14 +320,16 @@ function HeatmapPanel({
|
|||||||
tooltip,
|
tooltip,
|
||||||
onShowTooltip,
|
onShowTooltip,
|
||||||
onHideTooltip,
|
onHideTooltip,
|
||||||
|
onSelectCell,
|
||||||
}: {
|
}: {
|
||||||
data: ReturnType<typeof buildHeatmap>;
|
data: HeatmapData;
|
||||||
tooltip: FloatingTooltip | null;
|
tooltip: FloatingTooltip | null;
|
||||||
onShowTooltip: (tooltip: FloatingTooltip | null) => void;
|
onShowTooltip: (tooltip: FloatingTooltip | null) => void;
|
||||||
onHideTooltip: () => void;
|
onHideTooltip: () => void;
|
||||||
|
onSelectCell: (date: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<PanelCard>
|
<HeatmapCard>
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitleGroup>
|
<PanelTitleGroup>
|
||||||
<PanelTitle>
|
<PanelTitle>
|
||||||
@@ -312,21 +337,9 @@ function HeatmapPanel({
|
|||||||
학습 히트맵
|
학습 히트맵
|
||||||
</PanelTitle>
|
</PanelTitle>
|
||||||
<PanelDescription>
|
<PanelDescription>
|
||||||
어느 날 네 리듬이 가장 강했는지 한눈에 보여줘.
|
최근 {HEATMAP_WEEKS}주 동안 어느 날 학습 밀도가 높았는지 한눈에 보여줘.
|
||||||
</PanelDescription>
|
</PanelDescription>
|
||||||
</PanelTitleGroup>
|
</PanelTitleGroup>
|
||||||
|
|
||||||
<Legend>
|
|
||||||
<LegendLabel>적음</LegendLabel>
|
|
||||||
<LegendArrow>←</LegendArrow>
|
|
||||||
<LegendSwatches>
|
|
||||||
{data.legend.map((color, index) => (
|
|
||||||
<LegendSwatch key={index} $color={color} />
|
|
||||||
))}
|
|
||||||
</LegendSwatches>
|
|
||||||
<LegendArrow>→</LegendArrow>
|
|
||||||
<LegendLabel>많음</LegendLabel>
|
|
||||||
</Legend>
|
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
{data.columns.length === 0 ? (
|
{data.columns.length === 0 ? (
|
||||||
@@ -344,7 +357,7 @@ function HeatmapPanel({
|
|||||||
<MonthRow>
|
<MonthRow>
|
||||||
{data.columns.map((column, index) => (
|
{data.columns.map((column, index) => (
|
||||||
<MonthLabel key={column[0]?.key ?? index}>
|
<MonthLabel key={column[0]?.key ?? index}>
|
||||||
{column[0]?.isCurrentMonthLabel ? formatMonth(column[0].date) : ''}
|
{column[0]?.isMonthLabel ? formatMonth(column[0].date) : ''}
|
||||||
</MonthLabel>
|
</MonthLabel>
|
||||||
))}
|
))}
|
||||||
</MonthRow>
|
</MonthRow>
|
||||||
@@ -362,23 +375,48 @@ function HeatmapPanel({
|
|||||||
onShowTooltip({
|
onShowTooltip({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
title: cell.dateLabel,
|
title: `${cell.dateLabel}: ${cell.count}개 학습`,
|
||||||
subtitle: `${cell.count}개 학습`,
|
})
|
||||||
|
}
|
||||||
|
onFocus={() =>
|
||||||
|
onShowTooltip({
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
title: `${cell.dateLabel}: ${cell.count}개 학습`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onMouseLeave={onHideTooltip}
|
onMouseLeave={onHideTooltip}
|
||||||
|
onBlur={onHideTooltip}
|
||||||
|
onClick={() => onSelectCell(cell.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</WeekColumn>
|
</WeekColumn>
|
||||||
))}
|
))}
|
||||||
</GridColumns>
|
</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>
|
</HeatmapChart>
|
||||||
</HeatmapShell>
|
</HeatmapShell>
|
||||||
</HeatmapScroll>
|
</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>
|
<SelectWrap>
|
||||||
<TagSelect
|
<TagSelect
|
||||||
value={selectedTagId ?? ''}
|
value={selectedTagId}
|
||||||
onChange={(event) => {
|
onChange={(value) => onSelectTag(value as number)}
|
||||||
const nextValue = event.target.value;
|
options={options.map((option) => ({
|
||||||
onSelectTag(nextValue ? Number(nextValue) : null);
|
label: `${option.subjectName} · ${option.name}`,
|
||||||
}}
|
value: option.tagId,
|
||||||
|
}))}
|
||||||
|
placeholder="태그 없음"
|
||||||
disabled={options.length === 0}
|
disabled={options.length === 0}
|
||||||
aria-label="마스터리 곡선 태그 선택"
|
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>
|
</SelectWrap>
|
||||||
</PanelHeader>
|
</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({
|
function CurveChart({
|
||||||
points,
|
points,
|
||||||
tooltip,
|
tooltip,
|
||||||
@@ -683,43 +767,38 @@ function normalizeStudyLogResponse(payload: StudyLog[] | StudyLogListResponse):
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeatmap(logs: StudyLog[], range: RangeKey) {
|
function buildHeatmap(logs: StudyLog[]): HeatmapData {
|
||||||
const countByDate = new Map<string, number>();
|
const logsByDate = new Map<string, StudyLog[]>();
|
||||||
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
const key = toDateKey(log.studiedAt);
|
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 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 end = endOfWeek(today);
|
||||||
|
const start = startOfWeek(addDays(end, -(HEATMAP_WEEKS * 7 - 7)));
|
||||||
const days = eachDayBetween(start, end);
|
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[][] = [];
|
const columns: HeatmapCellData[][] = [];
|
||||||
|
|
||||||
for (let index = 0; index < days.length; index += 7) {
|
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 key = toDateKey(date.toISOString());
|
||||||
const count = countByDate.get(key) ?? 0;
|
const count = logsByDate.get(key)?.length ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
date,
|
date,
|
||||||
dateLabel: formatFullDate(date),
|
dateLabel: formatIsoDate(date),
|
||||||
count,
|
count,
|
||||||
level: resolveHeatLevel(count, maxCount),
|
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 {
|
return {
|
||||||
columns,
|
columns,
|
||||||
|
logsByDate,
|
||||||
legend: [
|
legend: [
|
||||||
theme.color.surfaceHoverDeep,
|
theme.color.surfaceHoverDeep,
|
||||||
'rgba(79, 70, 229, 0.18)',
|
'rgba(79, 70, 229, 0.18)',
|
||||||
'rgba(79, 70, 229, 0.36)',
|
'rgba(79, 70, 229, 0.36)',
|
||||||
'rgba(79, 70, 229, 0.62)',
|
'rgba(79, 70, 229, 0.62)',
|
||||||
theme.color.brandIndigo,
|
'#7C83FF',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -860,6 +940,12 @@ function eachDayBetween(start: Date, end: Date): Date[] {
|
|||||||
return result;
|
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 {
|
function toDateKey(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
@@ -872,11 +958,11 @@ function formatMonth(value: Date): string {
|
|||||||
return `${value.getMonth() + 1}월`;
|
return `${value.getMonth() + 1}월`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFullDate(value: Date): string {
|
function formatIsoDate(value: Date): string {
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
return `${year}.${month}.${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortDate(value: string): string {
|
function formatShortDate(value: string): string {
|
||||||
@@ -886,6 +972,19 @@ function formatShortDate(value: string): string {
|
|||||||
return `${month}.${day}`;
|
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`
|
const PageWrap = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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`
|
const PanelHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1061,24 +1166,24 @@ const LegendSwatch = styled.span<{ $color: string }>`
|
|||||||
|
|
||||||
const HeatmapScroll = styled.div`
|
const HeatmapScroll = styled.div`
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 8px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HeatmapShell = styled.div`
|
const HeatmapShell = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px minmax(560px, 1fr);
|
grid-template-columns: 22px max-content;
|
||||||
gap: ${theme.space.sm};
|
gap: ${theme.space.sm};
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||||
min-width: 620px;
|
min-width: 520px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HeatmapAxis = styled.div`
|
const HeatmapAxis = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 16px repeat(7, 16px);
|
grid-template-rows: 14px repeat(7, 15px);
|
||||||
row-gap: 8px;
|
row-gap: 5px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1093,15 +1198,15 @@ const AxisLabel = styled.span`
|
|||||||
const HeatmapChart = styled.div`
|
const HeatmapChart = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MonthRow = styled.div`
|
const MonthRow = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: 16px;
|
grid-auto-columns: 15px;
|
||||||
column-gap: 8px;
|
column-gap: 5px;
|
||||||
min-height: 16px;
|
min-height: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MonthLabel = styled.span`
|
const MonthLabel = styled.span`
|
||||||
@@ -1113,19 +1218,19 @@ const MonthLabel = styled.span`
|
|||||||
const GridColumns = styled.div`
|
const GridColumns = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: 16px;
|
grid-auto-columns: 15px;
|
||||||
column-gap: 8px;
|
column-gap: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const WeekColumn = styled.div`
|
const WeekColumn = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(7, 16px);
|
grid-template-rows: repeat(7, 15px);
|
||||||
row-gap: 8px;
|
row-gap: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HeatCell = styled.button<{ $level: 0 | 1 | 2 | 3 | 4 }>`
|
const HeatCell = styled.button<{ $level: 0 | 1 | 2 | 3 | 4 }>`
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
${({ $level }) =>
|
${({ $level }) =>
|
||||||
$level === 0 ? theme.color.borderSoftAlpha : 'transparent'};
|
$level === 0 ? theme.color.borderSoftAlpha : 'transparent'};
|
||||||
@@ -1217,18 +1322,11 @@ const AccuracyMeta = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const SelectWrap = styled.div`
|
const SelectWrap = styled.div`
|
||||||
min-width: 180px;
|
min-width: 220px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TagSelect = styled.select`
|
const TagSelect = styled(Select)`
|
||||||
width: 100%;
|
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`
|
const TagMeta = styled.div`
|
||||||
@@ -1292,6 +1390,147 @@ const EmptyPanel = styled.div`
|
|||||||
line-height: 1.7;
|
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`
|
const StateCard = styled.section`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import styled, { css } from 'styled-components';
|
import styled, { css } from 'styled-components';
|
||||||
import AppShell from '@/components/layout/AppShell';
|
import AppShell from '@/components/layout/AppShell';
|
||||||
|
import DatePicker from '@/components/ui/DatePicker';
|
||||||
import { Icon } from '@/components/ui/Icon';
|
import { Icon } from '@/components/ui/Icon';
|
||||||
|
import Select from '@/components/ui/Select';
|
||||||
import { api, type StudyLog, type StudyResult, type Subject } from '@/lib/api';
|
import { api, type StudyLog, type StudyResult, type Subject } from '@/lib/api';
|
||||||
import { theme } from '@/styles/theme';
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
@@ -191,35 +193,35 @@ function HistoryBody() {
|
|||||||
|
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={subjectFilter}
|
value={subjectFilter}
|
||||||
onChange={(event) => {
|
onChange={(value) => setSubjectFilter(value as number | 'all')}
|
||||||
const value = event.target.value;
|
options={[
|
||||||
setSubjectFilter(value === 'all' ? 'all' : Number(value));
|
{ label: '전체 과목', value: 'all' as const },
|
||||||
}}
|
...subjects.map((subject) => ({
|
||||||
>
|
label: subject.name,
|
||||||
<option value="all">전체 과목</option>
|
value: subject.id,
|
||||||
{subjects.map((subject) => (
|
})),
|
||||||
<option key={subject.id} value={subject.id}>
|
]}
|
||||||
{subject.name}
|
aria-label="과목 필터"
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</FilterSelect>
|
|
||||||
|
|
||||||
<DateRangeGroup>
|
<DateRangeGroup>
|
||||||
<DateInput
|
<DateInput
|
||||||
type="date"
|
value={startDate || null}
|
||||||
value={startDate}
|
max={endDate || undefined}
|
||||||
onChange={(event) => {
|
placeholder="시작 날짜"
|
||||||
setStartDate(event.target.value);
|
onChange={(nextValue) => {
|
||||||
|
setStartDate(nextValue);
|
||||||
setQuickRange('custom');
|
setQuickRange('custom');
|
||||||
}}
|
}}
|
||||||
aria-label="시작 날짜"
|
aria-label="시작 날짜"
|
||||||
/>
|
/>
|
||||||
<DateDash>~</DateDash>
|
<DateDash>~</DateDash>
|
||||||
<DateInput
|
<DateInput
|
||||||
type="date"
|
value={endDate || null}
|
||||||
value={endDate}
|
min={startDate || undefined}
|
||||||
onChange={(event) => {
|
placeholder="종료 날짜"
|
||||||
setEndDate(event.target.value);
|
onChange={(nextValue) => {
|
||||||
|
setEndDate(nextValue);
|
||||||
setQuickRange('custom');
|
setQuickRange('custom');
|
||||||
}}
|
}}
|
||||||
aria-label="종료 날짜"
|
aria-label="종료 날짜"
|
||||||
@@ -731,10 +733,8 @@ const SearchInput = styled.input`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FilterSelect = styled.select`
|
const FilterSelect = styled(Select)<{ }>`
|
||||||
${inputShared};
|
${inputShared};
|
||||||
padding: 0 14px;
|
|
||||||
appearance: none;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DateRangeGroup = styled.div`
|
const DateRangeGroup = styled.div`
|
||||||
@@ -748,10 +748,8 @@ const DateRangeGroup = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DateInput = styled.input`
|
const DateInput = styled(DatePicker)`
|
||||||
${inputShared};
|
${inputShared};
|
||||||
padding: 0 14px;
|
|
||||||
color-scheme: dark;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DateDash = styled.span`
|
const DateDash = styled.span`
|
||||||
|
|||||||
@@ -919,13 +919,20 @@ const TabsRow = styled.div`
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
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}) {
|
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||||
overflow-x: auto;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -934,13 +941,16 @@ const TabButton = styled.button<{ $active: boolean }>`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 0 16px;
|
padding: 10px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
scroll-snap-align: start;
|
||||||
border-radius: ${theme.radius.pill};
|
border-radius: ${theme.radius.pill};
|
||||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||||
background: rgba(21, 21, 28, 0.72);
|
background: rgba(21, 21, 28, 0.72);
|
||||||
color: ${theme.color.textSub};
|
color: ${theme.color.textSub};
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
transition: all 0.16s ease;
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
&:hover {
|
&: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