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,147 @@
import type { ButtonProps as AriaButtonProps } from 'react-aria-components';
import { Button as AriaButton, composeRenderProps } from 'react-aria-components';
import { tv } from 'tailwind-variants';
import { LoadingSpinnerIcon } from '../icons/LoadingSpinner';
export interface ButtonProps extends AriaButtonProps {
color?: 'primary' | 'light' | 'green' | 'lightGreen' | 'black' | 'gray' | 'orange' | 'navy' | 'lightNavy';
size?: 'small' | 'medium' | 'large';
stopPropagation?: boolean;
}
const button = tv({
base: 'relative inline-flex items-center justify-center gap-2 border cursor-pointer box-border data-focus-visible:ring-2 ring-offset-2',
variants: {
size: {
small: 'h-6 px-3 text-xs font-medium',
medium: 'h-9 px-3 text-xs font-medium',
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
},
color: {
primary: 'text-white bg-primary border-primary disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-primary',
light:
'text-primary border-primary bg-primary-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-primary',
green:
'text-white bg-kc-green-main border-kc-green-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-green-main',
lightGreen:
'text-kc-green-main border-kc-green-main bg-kc-green-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-green-main',
black:
'text-white bg-kc-black-34 border-kc-black-34 disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-black-34',
gray: 'text-kc-black-34 bg-kc-gray-eb border-kc-black-34 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-black-34',
orange:
'text-kc-orange-main bg-kc-orange-tertiary01 border-kc-orange-main disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-orange-main',
navy: 'text-white bg-kc-navy-main border-kc-navy-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-navy-main',
lightNavy:
'text-kc-navy-main border-kc-navy-main bg-kc-navy-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-navy-main',
},
isDisabled: {
true: 'cursor-not-allowed',
},
isPending: {
true: 'text-transparent cursor-progress',
},
},
compoundVariants: [
{
color: 'primary',
isDisabled: false,
isPending: false,
className:
'hover:border-primary-tertiary hover:bg-primary-tertiary active:bg-primary-secondary active:border-primary-secondary',
},
{
color: 'light',
isDisabled: false,
isPending: false,
className:
'hover:bg-primary-tertiary01 active:text-primary-secondary active:bg-primary-tertiary01 active:border-primary-secondary',
},
{
color: 'green',
isDisabled: false,
isPending: false,
className:
'hover:bg-kc-green-tertiary hover:border-kc-green-tertiary active:bg-kc-green-secondary active:border-kc-green-secondary',
},
{
color: 'lightGreen',
isDisabled: false,
isPending: false,
className:
'hover:bg-kc-green-tertiary01 active:text-kc-green-secondary active:bg-kc-green-tertiary01 active:border-kc-green-secondary',
},
{
color: 'black',
isDisabled: false,
isPending: false,
className: 'hover:bg-kc-black-47 hover:border-kc-black-47 active:bg-kc-black-22 active:border-kc-black-22',
},
{
color: 'gray',
isDisabled: false,
isPending: false,
className:
'hover:bg-kc-gray-da hover:border-kc-black-47 active:text-kc-black-22 active:bg-kc-gray-da active:border-kc-black-22',
},
{
color: 'orange',
isDisabled: false,
isPending: false,
className:
'hover:bg-kc-orange-tertiary02 active:text-kc-orange-secondary active:bg-kc-orange-tertiary02 active:border-kc-orange-secondary',
},
{
color: 'navy',
isDisabled: false,
isPending: false,
className:
'hover:border-kc-navy-tertiary hover:bg-kc-navy-tertiary active:bg-kc-navy-secondary active:border-kc-navy-secondary',
},
{
color: 'lightNavy',
isDisabled: false,
isPending: false,
className:
'hover:bg-kc-navy-tertiary01 active:text-kc-navy-secondary active:bg-kc-navy-tertiary01 active:border-kc-navy-secondary',
},
],
});
export const Button = (props: ButtonProps) => {
const {
className,
onClick,
color = 'primary',
size = 'medium',
children,
stopPropagation = false,
...restProps
} = props;
return (
<AriaButton
className={composeRenderProps(className, (className, renderProps) => {
return button({ ...renderProps, color, size, className });
})}
onClick={(e) => {
if (stopPropagation) {
e.stopPropagation();
}
onClick?.(e);
}}
{...restProps}
>
{composeRenderProps(children, (children, { isPending }) => (
<>
{children}
{isPending && (
<span aria-hidden className="flex absolute inset-0 justify-center items-center">
<LoadingSpinnerIcon className="w-4 h-4 text-white animate-spin" />
</span>
)}
</>
))}
</AriaButton>
);
};

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);
}

View File

@@ -0,0 +1,79 @@
import { DatePicker as AriaDatePicker, Button, Dialog, Group, OverlayArrow, Popover } from 'react-aria-components';
import dayjs from 'dayjs';
import { tv } from 'tailwind-variants';
import { Calendar } from '../calendar';
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
import { CalendarIcon } from '../icons';
const trigger = tv({
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
variants: {
isHovered: {
true: 'border-kc-black-34',
},
isFocused: {
true: 'border-kc-black-34',
},
isDisabled: {
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
});
export type DatePickerProps = {
value: Date | null;
onChange: (date: Date | null) => void;
maxValue?: Date | null;
minValue?: Date | null;
isDisabled?: boolean;
className?: string;
name?: string;
};
export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, className, name }: DatePickerProps) {
return (
<AriaDatePicker
aria-label="날짜 선택"
value={dateToCalendarDate(value)}
onChange={(val) => onChange(calendarDateToDate(val))}
maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined}
minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined}
isDisabled={isDisabled}
granularity="day"
firstDayOfWeek="sun"
className={className}
name={name}
>
<Group>
<Button className={(renderProps) => trigger(renderProps)}>
<span className={`flex-1 px-3 text-sm ${value ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
{value ? dayjs(value).format('YYYY.MM.DD') : 'YYYY.MM.DD'}
</span>
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
<CalendarIcon className="h-4 w-4" />
</span>
</Button>
</Group>
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
<OverlayArrow className="group/arrow">
<svg
width="12"
height="8"
viewBox="0 0 12 8"
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
>
<path d="M0 0 L6 8 L12 0" />
</svg>
</OverlayArrow>
<Dialog className="outline-none">
<Calendar />
</Dialog>
</Popover>
</AriaDatePicker>
);
}

View File

@@ -0,0 +1,113 @@
import {
DateRangePicker as AriaDateRangePicker,
Button,
Dialog,
Group,
OverlayArrow,
Popover,
} from 'react-aria-components';
import dayjs from 'dayjs';
import { tv } from 'tailwind-variants';
import { CalendarIcon } from '@/components/icons';
import { RangeCalendar } from '../calendar/RangeCalendar';
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
const trigger = tv({
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
variants: {
isHovered: {
true: 'border-kc-black-34',
},
isFocused: {
true: 'border-kc-black-34',
},
isDisabled: {
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
},
isFocusVisible: {
true: 'ring-2 ring-offset-2 ring-primary',
},
},
});
export type DateRangePickerProps = {
value: { start: Date; end: Date } | null;
onChange: (value: { start: Date; end: Date } | null) => void;
maxValue?: Date | null;
minValue?: Date | null;
isDisabled?: boolean;
className?: string;
startName?: string;
endName?: string;
};
export function DateRangePicker({
value,
onChange,
maxValue,
minValue,
isDisabled,
className,
startName,
endName,
}: DateRangePickerProps) {
const ariaValue = value ? { start: dateToCalendarDate(value.start)!, end: dateToCalendarDate(value.end)! } : null;
return (
<AriaDateRangePicker
aria-label="날짜 범위 선택"
value={ariaValue}
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}
granularity="day"
firstDayOfWeek="sun"
className={className}
startName={startName}
endName={endName}
>
<Group>
<Button className={(renderProps) => trigger(renderProps)}>
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
{value?.start ? dayjs(value.start).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
</span>
<span className="text-sm text-kc-gray-99">~</span>
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
{value?.end ? dayjs(value.end).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
</span>
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
<CalendarIcon className="h-4 w-4" />
</span>
</Button>
</Group>
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
<OverlayArrow className="group/arrow">
<svg
width="12"
height="8"
viewBox="0 0 12 8"
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
>
<path d="M0 0 L6 8 L12 0" />
</svg>
</OverlayArrow>
<Dialog className="outline-none">
<RangeCalendar />
</Dialog>
</Popover>
</AriaDateRangePicker>
);
}

View File

@@ -0,0 +1,4 @@
export { DatePicker } from './DatePicker';
export type { DatePickerProps } from './DatePicker';
export { DateRangePicker } from './DateRangePicker';
export type { DateRangePickerProps } from './DateRangePicker';

View File

@@ -0,0 +1,23 @@
import { type SVGProps } from 'react';
export function CalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { SVGProps } from 'react';
export function ChevronLeftIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m15 18-6-6 6-6" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { SVGProps } from 'react';
export function ChevronRightIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m9 18 6-6-6-6" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { type SVGProps } from 'react';
export function LoadingSpinnerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<circle cx="12" cy="12" r="10" strokeWidth="4" fill="none" className="opacity-25" />
<circle
cx="12"
cy="12"
r="10"
strokeWidth="4"
strokeLinecap="round"
fill="none"
pathLength="100"
strokeDasharray="60 140"
strokeDashoffset="0"
/>
</svg>
);
}

View File

@@ -0,0 +1,5 @@
export * from './Calendar';
export * from './ChevronLeft';
export * from './ChevronRight';
export * from './LoadingSpinner';

View File

@@ -0,0 +1,16 @@
import type { Ref } from 'react';
import type { InputProps as AriaInputProps } from 'react-aria-components';
import { Input as AriaInput } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import style from './style';
export interface InputProps extends AriaInputProps {
className?: string;
ref?: Ref<HTMLInputElement>;
}
export default function Input(props: InputProps) {
const { className, ...restProps } = props;
return <AriaInput className={twMerge(style().input(), className)} {...restProps} />;
}

View File

@@ -0,0 +1,51 @@
import { type PropsWithChildren } from 'react';
import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants';
export interface InputGroupProps extends PropsWithChildren {
className?: string;
innerClassName?: string;
isReadOnly?: boolean;
}
const style = tv({
slots: {
container: 'relative flex flex-col justify-center items-start bg-white',
base: [
'h-9',
'flex',
'items-center',
'text-sm',
'text-kc-black-34',
'leading-[18px]',
'border',
'border-kc-gray-be',
'has-data-focused:border-kc-black-34',
'has-data-hovered:border-kc-black-34',
'has-data-disabled:bg-kc-gray-eb',
'has-data-disabled:text-kc-gray-99',
'has-data-disabled:border-kc-gray-be',
'has-data-invalid:border-kc-red',
'has-data-invalid:has-data-focused:border-kc-red',
'has-data-invalid:has-data-hovered:border-kc-red',
'w-full',
'has-[[type=number]]:leading-6',
],
input: ['w-full', 'px-3', 'outline-0', '[&[type=number]]:pr-1.5'],
},
variants: {
isReadOnly: {
true: {
base: 'bg-kc-yellow-secondary has-data-hovered:border-kc-gray-be has-data-focused:border-kc-gray-be',
},
},
},
});
export const InputGroup = ({ className, innerClassName, isReadOnly = false, children }: InputGroupProps) => {
const styles = style({ isReadOnly });
return (
<div className={twMerge(styles.container(), className)}>
<div className={twMerge(styles.base(), innerClassName)}>{children}</div>
</div>
);
};

View File

@@ -0,0 +1,6 @@
import InternalInput from './Input';
import { InputGroup as InternalInputGroup } from './InputGroup';
export const InputGroup = Object.assign(InternalInputGroup, {
Input: InternalInput,
});

View File

@@ -0,0 +1,121 @@
import { useCallback, useMemo } from 'react';
import { Button as AriaButton } from 'react-aria-components';
type PaginationProps = {
totalPages: number;
currentPage: number;
pageCount?: number;
onPageChange: (page: number) => void;
};
// https://design-system.w3.org/components/pagination.html
export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChange }: PaginationProps) => {
const start = useMemo(() => {
return Math.floor(currentPage / pageCount) * pageCount;
}, [currentPage, pageCount]);
const handlePageClick = useCallback(
(page: number) => () => {
if (typeof onPageChange === 'function' && page >= 0 && page < totalPages) {
onPageChange(page);
}
},
[totalPages, onPageChange],
);
const pageArray = useMemo(() => {
const arr: number[] = [];
// totalPage가 0이면 아무것도 안나와서 최소 1이 되도록 수정
const _totalPages = totalPages > 0 ? totalPages : 1;
if (Number.isNaN(start)) {
return arr;
}
for (let i = 0; i < pageCount; i++) {
const pageNumber = start + i;
if (pageNumber >= _totalPages) {
continue;
}
arr.push(pageNumber);
}
return arr;
}, [start, pageCount, totalPages]);
const noPrev = start === 0;
const noNext = start + pageCount >= totalPages;
return (
<nav className="text-kc-gray-99 text-sm" aria-label="pagination">
<ul className="flex items-center justify-center list-none">
<li className="flex">
<AriaButton
className="cursor-pointer data-[disabled=true]:cursor-default"
isDisabled={noPrev}
aria-label="첫 페이지"
onClick={handlePageClick(0)}
>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15.8571" y="15" width="1.5" height="10" fill="currentColor" />
<path d="M24.1429 15.3571L19.1429 19.8571L24.1429 24.3571" stroke="currentColor" strokeWidth="1.5" />
</svg>
</AriaButton>
</li>
<li className="mr-2.5 flex">
<AriaButton
className="cursor-pointer data-[disabled=true]:cursor-default"
isDisabled={noPrev}
aria-label="이전 페이지"
onClick={handlePageClick(start - 1)}
>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5 15.5L17.5 20L22.5 24.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
</AriaButton>
</li>
{pageArray.map((pageNumber) => (
<li key={pageNumber}>
<AriaButton
isDisabled={currentPage === pageNumber}
className="w-10 h-10 flex items-center justify-center tabular-nums not-disabled:cursor-pointer aria-[current=page]:text-sm aria-[current=page]:text-primary aria-[current=page]:font-bold"
onClick={handlePageClick(pageNumber)}
aria-current={currentPage === pageNumber ? 'page' : undefined}
>
{pageNumber + 1}
</AriaButton>
</li>
))}
<li className="ml-2.5 flex">
<AriaButton
isDisabled={noNext}
className="cursor-pointer data-[disabled=true]:cursor-default"
// className="text-[#CCCCCC]"
aria-label="다음 페이지"
onClick={handlePageClick(start + pageCount)}
>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 15.5L22.5 20L17.5 24.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
</AriaButton>
</li>
<li className="flex">
<AriaButton
isDisabled={noNext}
className="cursor-pointer data-[disabled=true]:cursor-default"
// className="text-[#CCCCCC]"
aria-label="마지막 페이지"
onClick={handlePageClick(totalPages - 1)}
>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.75 15.5L20.75 20L15.75 24.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M24.25 15H22.75V25H24.25V15Z" fill="currentColor" />
</svg>
</AriaButton>
</li>
</ul>
</nav>
);
};

View File

@@ -22,6 +22,8 @@
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-router": "7.14.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"zod": "^4.3.6"
},
"devDependencies": {

54
web-app/pnpm-lock.yaml generated
View File

@@ -41,6 +41,12 @@ importers:
react-router:
specifier: 7.14.0
version: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwind-variants:
specifier: ^3.2.2
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -1170,42 +1176,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
@@ -1267,79 +1267,66 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@@ -1418,28 +1405,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
@@ -2076,28 +2059,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2464,6 +2443,19 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-variants@3.2.2:
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
engines: {node: '>=16.x', pnpm: '>=7.x'}
peerDependencies:
tailwind-merge: '>=3.0.0'
tailwindcss: '*'
peerDependenciesMeta:
tailwind-merge:
optional: true
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@@ -5570,6 +5562,14 @@ snapshots:
statuses@2.0.2: {}
tailwind-merge@3.5.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2):
dependencies:
tailwindcss: 4.2.2
optionalDependencies:
tailwind-merge: 3.5.0
tailwindcss@4.2.2: {}
tapable@2.3.2: {}