feat: button, calendar, datePicker, input, pagination, icons 추가

This commit is contained in:
2026-04-09 11:02:18 +09:00
parent 6da730f014
commit 34d5a56a80
19 changed files with 1048 additions and 27 deletions

View File

@@ -0,0 +1,182 @@
import {
Button as AriaButton,
Calendar as AriaCalendar,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarHeaderCell,
Heading,
} from 'react-aria-components';
import { getLocalTimeZone, today } from '@internationalized/date';
import { tv } from 'tailwind-variants';
import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons';
import { calendarDateToDate, dateToCalendarDate } from './utils';
const cellStyles = tv({
base: 'flex h-[30px] w-[30px] cursor-default items-center justify-center rounded-full text-xs outline-none',
variants: {
isSelected: {
true: 'bg-primary font-bold text-white',
false: '',
},
isDisabled: {
true: 'cursor-default text-kc-gray-be',
},
isUnavailable: {
true: 'cursor-default text-kc-gray-be line-through',
},
isOutsideMonth: {
true: 'text-kc-gray-be',
},
isSunday: {
true: 'text-[#e48686]',
},
isSaturday: {
true: 'text-[#7b8cc8]',
},
isToday: {
true: 'font-bold text-primary',
},
isHovered: {
true: 'bg-primary-tertiary01',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
compoundVariants: [
{
isSelected: true,
isSunday: true,
className: 'text-white',
},
{
isSelected: true,
isSaturday: true,
className: 'text-white',
},
{
isSelected: true,
isToday: true,
className: 'text-white',
},
{
isSelected: true,
isHovered: true,
className: 'bg-primary',
},
],
});
export interface CalendarProps {
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date | null) => void;
maxValue?: Date | null;
minValue?: Date | null;
isDisabled?: boolean;
className?: string;
}
export function Calendar({ value, defaultValue, onChange, maxValue, minValue, isDisabled, className }: CalendarProps) {
const todayDate = today(getLocalTimeZone());
return (
<AriaCalendar
aria-label="날짜 선택"
value={value !== undefined ? dateToCalendarDate(value ?? null) : undefined}
defaultValue={defaultValue !== undefined ? dateToCalendarDate(defaultValue ?? null) : undefined}
onChange={(val) => onChange?.(calendarDateToDate(val))}
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
isDisabled={isDisabled}
firstDayOfWeek="sun"
className={className}
>
<CalendarHeader />
<CalendarGrid className="border-spacing-0">
<CalendarGridHeader />
<CalendarGridBody>
{(date) => {
const dayOfWeek = date.toDate('UTC').getDay();
const isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6;
const isToday = date.compare(todayDate) === 0;
return (
<CalendarCell
date={date}
className={({
isSelected,
isDisabled: isCellDisabled,
isUnavailable,
isOutsideMonth,
isHovered,
isFocusVisible,
}) =>
cellStyles({
isSelected,
isDisabled: isCellDisabled,
isUnavailable,
isOutsideMonth,
isSunday: !isSelected && !isDisabled && !isOutsideMonth ? isSunday : false,
isSaturday: !isSelected && !isDisabled && !isOutsideMonth ? isSaturday : false,
isToday: !isSelected ? isToday : false,
isHovered: !isSelected ? isHovered : false,
isFocusVisible,
})
}
/>
);
}}
</CalendarGridBody>
</CalendarGrid>
</AriaCalendar>
);
}
const navButton = tv({
base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
variants: {
isDisabled: {
true: 'cursor-default text-kc-gray-be',
false: 'cursor-pointer text-kc-black-34 hover:bg-kc-gray-eb',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
defaultVariants: {
isDisabled: false,
},
});
export function CalendarHeader() {
return (
<header className="flex items-center gap-1 pb-3">
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
<ChevronLeftIcon className="h-4 w-4" />
</AriaButton>
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-kc-black-34" />
<AriaButton slot="next" className={(renderProps) => navButton(renderProps)}>
<ChevronRightIcon className="h-4 w-4" />
</AriaButton>
</header>
);
}
export function CalendarGridHeader() {
return (
<AriaCalendarGridHeader>
{(day) => (
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-kc-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
{day}
</CalendarHeaderCell>
)}
</AriaCalendarGridHeader>
);
}

View File

@@ -0,0 +1,195 @@
import {
RangeCalendar as AriaRangeCalendar,
CalendarCell,
CalendarGrid,
CalendarGridBody,
type DateValue,
} from 'react-aria-components';
import { getLocalTimeZone, today } from '@internationalized/date';
import { tv } from 'tailwind-variants';
import { CalendarGridHeader, CalendarHeader } from './Calendar';
import { calendarDateToDate, dateToCalendarDate } from './utils';
const rangeCell = tv({
base: 'flex h-full w-full items-center justify-center rounded-full text-xs',
variants: {
selectionState: {
none: '',
middle: '',
cap: 'bg-primary font-bold text-white',
},
isDisabled: {
true: 'text-kc-gray-be',
},
isOutsideMonth: {
true: 'text-kc-gray-be',
},
isSunday: {
true: 'text-[#e48686]',
},
isSaturday: {
true: 'text-[#7b8cc8]',
},
isToday: {
true: 'font-bold text-primary',
},
isHovered: {
true: '',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
compoundVariants: [
{
selectionState: 'cap',
isSunday: true,
className: 'text-white',
},
{
selectionState: 'cap',
isSaturday: true,
className: 'text-white',
},
{
selectionState: 'cap',
isToday: true,
className: 'text-white',
},
{
selectionState: 'none',
isHovered: true,
className: 'bg-primary-tertiary01 rounded-full',
},
{
selectionState: 'middle',
isHovered: true,
className: 'bg-primary-tertiary01',
},
],
});
export interface RangeCalendarProps {
value?: { start: Date; end: Date } | null;
defaultValue?: { start: Date; end: Date } | null;
onChange?: (value: { start: Date; end: Date } | null) => void;
maxValue?: Date | null;
minValue?: Date | null;
isDisabled?: boolean;
className?: string;
}
export function RangeCalendar({
value,
defaultValue,
onChange,
maxValue,
minValue,
isDisabled,
className,
}: RangeCalendarProps) {
const todayDate = today(getLocalTimeZone());
const ariaValue =
value !== undefined
? value
? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! }
: null
: undefined;
const ariaDefaultValue =
defaultValue !== undefined
? defaultValue
? { start: dateToCalendarDate(defaultValue.start)!, end: dateToCalendarDate(defaultValue.end)! }
: null
: undefined;
return (
<AriaRangeCalendar<DateValue>
aria-label="날짜 범위 선택"
value={ariaValue}
defaultValue={ariaDefaultValue}
onChange={(range) => {
if (!range) {
onChange?.(null);
} else {
onChange?.({
start: calendarDateToDate(range.start)!,
end: calendarDateToDate(range.end)!,
});
}
}}
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
isDisabled={isDisabled}
firstDayOfWeek="sun"
className={className}
>
<CalendarHeader />
<CalendarGrid className="border-spacing-0 [&_td]:px-0 [&_td]:py-px">
<CalendarGridHeader />
<CalendarGridBody>
{(date) => {
const dayOfWeek = date.toDate('UTC').getDay();
const isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6;
const isToday = date.compare(todayDate) === 0;
return (
<CalendarCell
date={date}
className={({ isSelected, isSelectionStart, isSelectionEnd }) => {
const classes = ['group h-[30px] w-[30px] cursor-default text-sm outline-none'];
if (isSelected) {
classes.push('bg-primary/10');
if (isSelectionStart) classes.push('rounded-s-full');
if (isSelectionEnd) classes.push('rounded-e-full');
}
return classes.join(' ');
}}
>
{({
formattedDate,
isSelected,
isSelectionStart,
isSelectionEnd,
isDisabled: isCellDisabled,
isOutsideMonth,
isHovered,
isFocusVisible,
}) => {
const selectionState =
isSelected && (isSelectionStart || isSelectionEnd)
? ('cap' as const)
: isSelected
? ('middle' as const)
: ('none' as const);
const showDayColor = selectionState !== 'cap' && !isCellDisabled && !isOutsideMonth;
return (
<span
className={rangeCell({
selectionState,
isDisabled: isCellDisabled,
isOutsideMonth,
isSunday: showDayColor ? isSunday : false,
isSaturday: showDayColor ? isSaturday : false,
isToday: selectionState !== 'cap' ? isToday : false,
isHovered: !isSelected ? isHovered : false,
isFocusVisible,
})}
>
{formattedDate}
</span>
);
}}
</CalendarCell>
);
}}
</CalendarGridBody>
</CalendarGrid>
</AriaRangeCalendar>
);
}

View File

@@ -0,0 +1,4 @@
export { Calendar } from './Calendar';
export type { CalendarProps } from './Calendar';
export { RangeCalendar } from './RangeCalendar';
export type { RangeCalendarProps } from './RangeCalendar';

View File

@@ -0,0 +1,13 @@
import { CalendarDate, type DateValue, fromDate, toCalendarDate } from '@internationalized/date';
const TIMEZONE = 'Asia/Seoul';
export function dateToCalendarDate(date: Date | null): CalendarDate | null {
if (!date) return null;
return toCalendarDate(fromDate(date, TIMEZONE));
}
export function calendarDateToDate(value: DateValue | null): Date | null {
if (!value) return null;
return value.toDate(TIMEZONE);
}