Skip to content

[WRFE-91](refactor): popover, calendar, timepicker 스토리북 수정 #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 55 additions & 17 deletions packages/ui/src/ui/calendar/Calendar.mdx
Original file line number Diff line number Diff line change
@@ -1,45 +1,83 @@
{/* Calendar.mdx */}

import * as stories from './Calendar.stories';
import {Canvas, Meta, Primary, Controls, Description} from '@storybook/blocks';

import * as CalendarStories from './Calendar.stories';

<Meta of={CalendarStories} />
<Meta of={stories} />

# Calendar

CalendarForm 컴포넌트는 Popover태그를 사용하여 SingleDayPickCalendar 컴포넌트를 나타내는 컴포넌트 입니다.
`Calendar`는 날짜를 표시하고, 특정 날짜를 선택할 수 있도록 돕는 컴포넌트입니다.

---

- [Overview](#overview)
- [Props](#props)
- [Description](#description)

## Overview

<Primary />

## Props

<Controls />

---

## Description

Calendar컴포넌트 같은 경우, option 중 mode로 Calendar안에서 Selection Modes를 single, multi, range 3가지중 고를 수 있으나,
모드를 Props로 받지 않은 이유는 피그마상에서 날짜를 단일로 고르는 경우만 존재하는것 같아 굳이 Props로 받지 않기로 했습니다.
`Calendar` 컴포넌트는 `react-day-picker`의 `DayPicker`를 기반으로 하며, 다음과 같은 기능을 제공합니다:

그래서 SingleDayPickCalendar로 이름을 지었으나, 추후에 디자인 변경 or 추가가 있을 경우, 수정하겠습니다.
### 선택 모드

컨테이너의 width에 따라 변경 가능하도록 width를 따로 고정값을 주지 않았습니다.
- `single`: 단일 날짜 선택
- `multiple`: 여러 날짜 선택
- `range`: 날짜 범위 선택

응모기간을 생각해서 당일 이전의 날짜는 선택 불가하도록 구현하였습니다.
### 기본 Props

## Props
`DayPickerBase`의 모든 props를 사용할 수 있습니다:

<Controls />
- `locale`: 날짜 포맷팅에 사용할 locale
- `numberOfMonths`: 표시할 달력의 개수
- `showOutsideDays`: 이전/다음 달의 날짜 표시 여부
- `showWeekNumber`: 주 번호 표시 여부
- `weekStartsOn`: 주의 시작일 (0: 일요일)
- `disabled`: 비활성화할 날짜
- `hidden`: 숨길 날짜
- `today`: 오늘 날짜로 표시할 날짜
- `fromDate`: 선택 가능한 최초 날짜
- `toDate`: 선택 가능한 최종 날짜
- `fromMonth`: 선택 가능한 최초 달
- `toMonth`: 선택 가능한 최종 달
- `fromYear`: 선택 가능한 최초 연도
- `toYear`: 선택 가능한 최종 연도

### 모드별 Props

## Stories
#### Single 모드 (`mode="single"`)

### Calendar With FromDate
- `selected`: 선택된 날짜 (`Date | undefined`)
- `onSelect`: 날짜 선택 시 호출되는 이벤트 핸들러
- `required`: 선택을 필수로 할지 여부 (기본값: `false`)

고를수 있는 시작일을 fromDate를 통해 설정할 수 있습니다.(예시. 오늘부터)
#### Multiple 모드 (`mode="multiple"`)

<Canvas of={CalendarStories.CalendarWithFromDate} meta={CalendarStories} />
- `selected`: 선택된 날짜 배열 (`Date[] | undefined`)
- `onSelect`: 날짜 선택/해제 시 호출되는 이벤트 핸들러
- `min`: 선택 가능한 최소 날짜 수
- `max`: 선택 가능한 최대 날짜 수

### Selected Calendar Form
#### Range 모드 (`mode="range"`)

<Canvas of={CalendarStories.CalendarFormSelected} meta={CalendarStories} />
- `selected`: 선택된 날짜 범위 (`DateRange | undefined`)
```typescript
type DateRange = {
from: Date | undefined;
to?: Date | undefined;
};
```
- `onSelect`: 날짜 범위 선택 시 호출되는 이벤트 핸들러
- `min`: 선택 가능한 최소 날짜 수
- `max`: 선택 가능한 최대 날짜 수
81 changes: 55 additions & 26 deletions packages/ui/src/ui/calendar/Calendar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,86 @@
import {CalendarForm} from './Calendar';
import {Calendar} from './Calendar';
import {useState} from 'react';
import type {SelectRangeEventHandler, DateRange} from 'react-day-picker';
import type {Meta, StoryObj} from '@storybook/react';

const meta: Meta<typeof CalendarForm> = {
component: CalendarForm,
const meta: Meta<typeof Calendar> = {
title: 'Components/Calendar',
component: Calendar,
argTypes: {
mode: {
control: 'select',
options: ['single', 'multiple', 'range'],
},
selected: {
control: false,
},
onSelect: {
control: false,
},
required: {
control: 'boolean',
},
min: {
control: 'number',
if: {arg: 'mode', eq: 'multiple'},
},
max: {
control: 'number',
if: {arg: 'mode', eq: 'multiple'},
},
},
};

export default meta;

type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Calendar>;

export const CalendarFormDefault: Story = {
name: 'Default',
args: {
dateLabel: '선택한 날짜가 표시되기 전 보여지는 라벨입니다',
},
export const Single: Story = {
render: args => {
const [selected, setSelected] = useState<Date | undefined>();
const [selected, setSelected] = useState<Date | undefined>(new Date());
return (
<CalendarForm
<Calendar
{...args}
mode='single'
selected={selected}
setSelected={setSelected}
fromDate={new Date()}
onSelect={setSelected}
/>
);
},
args: {
mode: 'single',
required: false,
},
};

export const CalendarWithFromDate: Story = {
name: 'FromDate',
args: {...CalendarFormDefault.args},
export const Multiple: Story = {
render: args => {
const [selected, setSelected] = useState<Date | undefined>();
const [selected, setSelected] = useState<Date[] | undefined>([new Date()]);
return (
<CalendarForm
<Calendar
{...args}
fromDate={new Date()}
mode='multiple'
selected={selected}
setSelected={setSelected}
onSelect={setSelected}
/>
);
},
args: {
mode: 'multiple',
min: 1,
max: 5,
},
};

export const CalendarFormSelected: Story = {
name: 'Selected',
args: {...CalendarFormDefault.args},
render: args => {
const [selected, setSelected] = useState<Date | undefined>(new Date());
export const Range: Story = {
render: () => {
const [selected, setSelected] = useState<DateRange | undefined>({
from: new Date(2025, 2, 1),
to: new Date(2025, 2, 3),
});
const handleSelect: SelectRangeEventHandler = range => setSelected(range);
return (
<CalendarForm {...args} selected={selected} setSelected={setSelected} />
<Calendar mode='range' selected={selected} onSelect={handleSelect} />
);
},
};
102 changes: 19 additions & 83 deletions packages/ui/src/ui/calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,41 @@
import clsx from 'clsx';
import {format} from 'date-fns';
import {ko} from 'date-fns/locale';
import {useState} from 'react';
import type {DayPickerProps} from 'react-day-picker';
import {DayPicker} from 'react-day-picker';
import {CalendarIcon} from '@radix-ui/react-icons';
import * as Popover from '@radix-ui/react-popover';
import {cn} from '@wds/shared/utils';

interface CalendarFormProps {
dateLabel: string;
selected: Date | undefined;
onSelect: (selected: Date | undefined) => void;
fromDate: Date | undefined;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

interface CalendarProps {
selected: Date | undefined;
onSelect: (selected: Date | undefined) => void;
setCalendarOpen: (calendarOpen: boolean) => void;
fromDate: Date | undefined;
}

export const CalendarForm = ({
dateLabel,
selected,
onSelect,
fromDate,
onClick,
}: CalendarFormProps) => {
const [calendarOpen, setCalendarOpen] = useState<boolean>(false);
return (
<Popover.Root open={calendarOpen} onOpenChange={setCalendarOpen}>
<Popover.Trigger asChild>
<button
onClick={onClick}
className={clsx(
'flex w-full items-center justify-between gap-3 px-3 py-3.5',
selected ? 'text-black' : 'text-[#ADB5BD]',
'truncate rounded-lg border-2 border-solid border-[#F5F5F7] bg-[#FAFAFB] text-sm font-medium hover:bg-accent hover:text-accent-foreground',
)}
>
{selected ? format(selected, 'PPP', {locale: ko}) : dateLabel}
<CalendarIcon className='h-5 w-5 text-black' />
</button>
</Popover.Trigger>

<Popover.Portal>
<Popover.Content
align='center'
sideOffset={5}
className={clsx(
'z-50 w-[--radix-popover-trigger-width] rounded-md border bg-white p-4 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
)}
>
<SingleDayPickCalendar
selected={selected}
onSelect={onSelect}
setCalendarOpen={setCalendarOpen}
fromDate={fromDate}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};

const SingleDayPickCalendar = ({
selected,
onSelect,
setCalendarOpen,
fromDate,
}: CalendarProps) => {
export const Calendar = ({className, ...props}: DayPickerProps) => {
return (
<DayPicker
mode='single'
selected={selected}
onSelect={date => {
onSelect(date);
setCalendarOpen(false);
}}
required
fromDate={fromDate}
locale={ko}
className={cn('min-w-[260px] p-3', className)}
classNames={{
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
nav_button_previous: 'absolute left-2',
nav_button_next: 'absolute right-0',
table: 'w-full',
head_cell: 'text-muted-foreground font-normal text-[0.8rem]',
cell: 'relative p-0 text-center focus-within:relative focus-within:z-20',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md [&:has(>.day-range-start):not(:has(>.day-range-end))]:bg-gradient-to-r [&:has(>.day-range-start):not(:has(>.day-range-end))]:from-transparent [&:has(>.day-range-start):not(:has(>.day-range-end))]:to-accent [&:has(>.day-range-end):not(:has(>.day-range-start))]:bg-gradient-to-l [&:has(>.day-range-end):not(:has(>.day-range-start))]:from-transparent [&:has(>.day-range-end):not(:has(>.day-range-start))]:to-accent [&:has([aria-selected]:not(.day-range-start):not(.day-range-end))]:bg-accent [&:has(>.day-range-start)][&:has(>.day-range-end)]:bg-none'
: '[&:has([aria-selected])]:rounded-md',
),
day: 'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_disabled: 'text-muted-foreground opacity-20',
day_selected:
'bg-primary font-bold rounded text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent rounded text-accent-foreground',
day_today: 'bg-accent text-accent-foreground rounded-md',
day_range_middle:
'aria-selected:bg-accent/30 aria-selected:text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50',
}}
{...props}
/>
);
};
Loading