feat(ui): themed Select/DatePicker + compact heatmap + subjects mobile fix — 7G.5

This commit is contained in:
reloop
2026-04-12 07:38:17 +09:00
parent feb1a624a5
commit e332d270c8
6 changed files with 1375 additions and 134 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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`

View File

@@ -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 {

View 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;
}
`;

View 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};
}
`;