From 34d5a56a808311e8d476beed93d25c3ce14a5ff4 Mon Sep 17 00:00:00 2001 From: "JoohyunKim(Lucy)" Date: Thu, 9 Apr 2026 11:02:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20button,=20calendar,=20datePicker,=20inp?= =?UTF-8?q?ut,=20pagination,=20icons=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/shared/components/button/Button.tsx | 147 +++++++++++++ .../shared/components/calendar/Calendar.tsx | 182 ++++++++++++++++ .../components/calendar/RangeCalendar.tsx | 195 ++++++++++++++++++ .../app/shared/components/calendar/index.ts | 4 + .../app/shared/components/calendar/utils.ts | 13 ++ .../components/datePicker/DatePicker.tsx | 79 +++++++ .../components/datePicker/DateRangePicker.tsx | 113 ++++++++++ .../app/shared/components/datePicker/index.ts | 4 + .../app/shared/components/icons/Calendar.tsx | 23 +++ .../shared/components/icons/ChevronLeft.tsx | 20 ++ .../shared/components/icons/ChevronRight.tsx | 20 ++ .../components/icons/LoadingSpinner.tsx | 20 ++ web-app/app/shared/components/icons/index.ts | 5 + .../shared/components/inputGroup/Input.tsx | 16 ++ .../components/inputGroup/InputGroup.tsx | 51 +++++ .../app/shared/components/inputGroup/index.ts | 6 + .../components/pagination/Pagination.tsx | 121 +++++++++++ web-app/package.json | 2 + web-app/pnpm-lock.yaml | 54 ++--- 19 files changed, 1048 insertions(+), 27 deletions(-) create mode 100644 web-app/app/shared/components/button/Button.tsx create mode 100644 web-app/app/shared/components/calendar/Calendar.tsx create mode 100644 web-app/app/shared/components/calendar/RangeCalendar.tsx create mode 100644 web-app/app/shared/components/calendar/index.ts create mode 100644 web-app/app/shared/components/calendar/utils.ts create mode 100644 web-app/app/shared/components/datePicker/DatePicker.tsx create mode 100644 web-app/app/shared/components/datePicker/DateRangePicker.tsx create mode 100644 web-app/app/shared/components/datePicker/index.ts create mode 100644 web-app/app/shared/components/icons/Calendar.tsx create mode 100644 web-app/app/shared/components/icons/ChevronLeft.tsx create mode 100644 web-app/app/shared/components/icons/ChevronRight.tsx create mode 100644 web-app/app/shared/components/icons/LoadingSpinner.tsx create mode 100644 web-app/app/shared/components/icons/index.ts create mode 100644 web-app/app/shared/components/inputGroup/Input.tsx create mode 100644 web-app/app/shared/components/inputGroup/InputGroup.tsx create mode 100644 web-app/app/shared/components/inputGroup/index.ts create mode 100644 web-app/app/shared/components/pagination/Pagination.tsx diff --git a/web-app/app/shared/components/button/Button.tsx b/web-app/app/shared/components/button/Button.tsx new file mode 100644 index 0000000..d9eda45 --- /dev/null +++ b/web-app/app/shared/components/button/Button.tsx @@ -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 ( + { + return button({ ...renderProps, color, size, className }); + })} + onClick={(e) => { + if (stopPropagation) { + e.stopPropagation(); + } + onClick?.(e); + }} + {...restProps} + > + {composeRenderProps(children, (children, { isPending }) => ( + <> + {children} + {isPending && ( + + + + )} + + ))} + + ); +}; diff --git a/web-app/app/shared/components/calendar/Calendar.tsx b/web-app/app/shared/components/calendar/Calendar.tsx new file mode 100644 index 0000000..d63e673 --- /dev/null +++ b/web-app/app/shared/components/calendar/Calendar.tsx @@ -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 ( + onChange?.(calendarDateToDate(val))} + maxValue={maxValue !== undefined ? dateToCalendarDate(maxValue) : undefined} + minValue={minValue !== undefined ? dateToCalendarDate(minValue) : undefined} + isDisabled={isDisabled} + firstDayOfWeek="sun" + className={className} + > + + + + + {(date) => { + const dayOfWeek = date.toDate('UTC').getDay(); + const isSunday = dayOfWeek === 0; + const isSaturday = dayOfWeek === 6; + const isToday = date.compare(todayDate) === 0; + + return ( + + 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, + }) + } + /> + ); + }} + + + + ); +} + +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 ( +
+ navButton(renderProps)}> + + + + navButton(renderProps)}> + + +
+ ); +} + +export function CalendarGridHeader() { + return ( + + {(day) => ( + + {day} + + )} + + ); +} diff --git a/web-app/app/shared/components/calendar/RangeCalendar.tsx b/web-app/app/shared/components/calendar/RangeCalendar.tsx new file mode 100644 index 0000000..987cab1 --- /dev/null +++ b/web-app/app/shared/components/calendar/RangeCalendar.tsx @@ -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 ( + + 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} + > + + + + + {(date) => { + const dayOfWeek = date.toDate('UTC').getDay(); + const isSunday = dayOfWeek === 0; + const isSaturday = dayOfWeek === 6; + const isToday = date.compare(todayDate) === 0; + + return ( + { + 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 ( + + {formattedDate} + + ); + }} + + ); + }} + + + + ); +} diff --git a/web-app/app/shared/components/calendar/index.ts b/web-app/app/shared/components/calendar/index.ts new file mode 100644 index 0000000..3113718 --- /dev/null +++ b/web-app/app/shared/components/calendar/index.ts @@ -0,0 +1,4 @@ +export { Calendar } from './Calendar'; +export type { CalendarProps } from './Calendar'; +export { RangeCalendar } from './RangeCalendar'; +export type { RangeCalendarProps } from './RangeCalendar'; diff --git a/web-app/app/shared/components/calendar/utils.ts b/web-app/app/shared/components/calendar/utils.ts new file mode 100644 index 0000000..a944d82 --- /dev/null +++ b/web-app/app/shared/components/calendar/utils.ts @@ -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); +} diff --git a/web-app/app/shared/components/datePicker/DatePicker.tsx b/web-app/app/shared/components/datePicker/DatePicker.tsx new file mode 100644 index 0000000..f3b1987 --- /dev/null +++ b/web-app/app/shared/components/datePicker/DatePicker.tsx @@ -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 ( + 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} + > + + + + + + + + + + + + + + + ); +} diff --git a/web-app/app/shared/components/datePicker/DateRangePicker.tsx b/web-app/app/shared/components/datePicker/DateRangePicker.tsx new file mode 100644 index 0000000..c88f176 --- /dev/null +++ b/web-app/app/shared/components/datePicker/DateRangePicker.tsx @@ -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 ( + { + 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} + > + + + + + + + + + + + + + + + ); +} diff --git a/web-app/app/shared/components/datePicker/index.ts b/web-app/app/shared/components/datePicker/index.ts new file mode 100644 index 0000000..419227f --- /dev/null +++ b/web-app/app/shared/components/datePicker/index.ts @@ -0,0 +1,4 @@ +export { DatePicker } from './DatePicker'; +export type { DatePickerProps } from './DatePicker'; +export { DateRangePicker } from './DateRangePicker'; +export type { DateRangePickerProps } from './DateRangePicker'; diff --git a/web-app/app/shared/components/icons/Calendar.tsx b/web-app/app/shared/components/icons/Calendar.tsx new file mode 100644 index 0000000..2d15f4d --- /dev/null +++ b/web-app/app/shared/components/icons/Calendar.tsx @@ -0,0 +1,23 @@ +import { type SVGProps } from 'react'; + +export function CalendarIcon(props: SVGProps) { + return ( + + + + + + + ); +} diff --git a/web-app/app/shared/components/icons/ChevronLeft.tsx b/web-app/app/shared/components/icons/ChevronLeft.tsx new file mode 100644 index 0000000..eb93fad --- /dev/null +++ b/web-app/app/shared/components/icons/ChevronLeft.tsx @@ -0,0 +1,20 @@ +import { SVGProps } from 'react'; + +export function ChevronLeftIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/web-app/app/shared/components/icons/ChevronRight.tsx b/web-app/app/shared/components/icons/ChevronRight.tsx new file mode 100644 index 0000000..3166602 --- /dev/null +++ b/web-app/app/shared/components/icons/ChevronRight.tsx @@ -0,0 +1,20 @@ +import { SVGProps } from 'react'; + +export function ChevronRightIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/web-app/app/shared/components/icons/LoadingSpinner.tsx b/web-app/app/shared/components/icons/LoadingSpinner.tsx new file mode 100644 index 0000000..b682e35 --- /dev/null +++ b/web-app/app/shared/components/icons/LoadingSpinner.tsx @@ -0,0 +1,20 @@ +import { type SVGProps } from 'react'; + +export function LoadingSpinnerIcon(props: SVGProps) { + return ( + + + + + ); +} diff --git a/web-app/app/shared/components/icons/index.ts b/web-app/app/shared/components/icons/index.ts new file mode 100644 index 0000000..7df7603 --- /dev/null +++ b/web-app/app/shared/components/icons/index.ts @@ -0,0 +1,5 @@ +export * from './Calendar'; +export * from './ChevronLeft'; +export * from './ChevronRight'; +export * from './LoadingSpinner'; + diff --git a/web-app/app/shared/components/inputGroup/Input.tsx b/web-app/app/shared/components/inputGroup/Input.tsx new file mode 100644 index 0000000..785b164 --- /dev/null +++ b/web-app/app/shared/components/inputGroup/Input.tsx @@ -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; +} +export default function Input(props: InputProps) { + const { className, ...restProps } = props; + return ; +} diff --git a/web-app/app/shared/components/inputGroup/InputGroup.tsx b/web-app/app/shared/components/inputGroup/InputGroup.tsx new file mode 100644 index 0000000..04f7ac5 --- /dev/null +++ b/web-app/app/shared/components/inputGroup/InputGroup.tsx @@ -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 ( +
+
{children}
+
+ ); +}; diff --git a/web-app/app/shared/components/inputGroup/index.ts b/web-app/app/shared/components/inputGroup/index.ts new file mode 100644 index 0000000..0e22a81 --- /dev/null +++ b/web-app/app/shared/components/inputGroup/index.ts @@ -0,0 +1,6 @@ +import InternalInput from './Input'; +import { InputGroup as InternalInputGroup } from './InputGroup'; + +export const InputGroup = Object.assign(InternalInputGroup, { + Input: InternalInput, +}); diff --git a/web-app/app/shared/components/pagination/Pagination.tsx b/web-app/app/shared/components/pagination/Pagination.tsx new file mode 100644 index 0000000..294555f --- /dev/null +++ b/web-app/app/shared/components/pagination/Pagination.tsx @@ -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 ( + + ); +}; diff --git a/web-app/package.json b/web-app/package.json index 97dc105..5e23bba 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -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": { diff --git a/web-app/pnpm-lock.yaml b/web-app/pnpm-lock.yaml index 280b6a9..3bffe80 100644 --- a/web-app/pnpm-lock.yaml +++ b/web-app/pnpm-lock.yaml @@ -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: {}