diff --git a/packages/ui/src/ui/calendar/Calendar.mdx b/packages/ui/src/ui/calendar/Calendar.mdx index 73f910a4..0da621c8 100644 --- a/packages/ui/src/ui/calendar/Calendar.mdx +++ b/packages/ui/src/ui/calendar/Calendar.mdx @@ -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'; - - + # Calendar -CalendarForm 컴포넌트는 Popover태그를 사용하여 SingleDayPickCalendar 컴포넌트를 나타내는 컴포넌트 입니다. +`Calendar`는 날짜를 표시하고, 특정 날짜를 선택할 수 있도록 돕는 컴포넌트입니다. + +--- - [Overview](#overview) - [Props](#props) +- [Description](#description) ## Overview +## Props + + + +--- + ## 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를 사용할 수 있습니다: - +- `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"`) - +- `selected`: 선택된 날짜 배열 (`Date[] | undefined`) +- `onSelect`: 날짜 선택/해제 시 호출되는 이벤트 핸들러 +- `min`: 선택 가능한 최소 날짜 수 +- `max`: 선택 가능한 최대 날짜 수 -### Selected Calendar Form +#### Range 모드 (`mode="range"`) - +- `selected`: 선택된 날짜 범위 (`DateRange | undefined`) + ```typescript + type DateRange = { + from: Date | undefined; + to?: Date | undefined; + }; + ``` +- `onSelect`: 날짜 범위 선택 시 호출되는 이벤트 핸들러 +- `min`: 선택 가능한 최소 날짜 수 +- `max`: 선택 가능한 최대 날짜 수 diff --git a/packages/ui/src/ui/calendar/Calendar.stories.tsx b/packages/ui/src/ui/calendar/Calendar.stories.tsx index d3fab92f..ba6d8ac1 100644 --- a/packages/ui/src/ui/calendar/Calendar.stories.tsx +++ b/packages/ui/src/ui/calendar/Calendar.stories.tsx @@ -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 = { - component: CalendarForm, +const meta: Meta = { 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; +type Story = StoryObj; -export const CalendarFormDefault: Story = { - name: 'Default', - args: { - dateLabel: '선택한 날짜가 표시되기 전 보여지는 라벨입니다', - }, +export const Single: Story = { render: args => { - const [selected, setSelected] = useState(); + const [selected, setSelected] = useState(new Date()); return ( - ); }, + args: { + mode: 'single', + required: false, + }, }; -export const CalendarWithFromDate: Story = { - name: 'FromDate', - args: {...CalendarFormDefault.args}, +export const Multiple: Story = { render: args => { - const [selected, setSelected] = useState(); + const [selected, setSelected] = useState([new Date()]); return ( - ); }, + args: { + mode: 'multiple', + min: 1, + max: 5, + }, }; -export const CalendarFormSelected: Story = { - name: 'Selected', - args: {...CalendarFormDefault.args}, - render: args => { - const [selected, setSelected] = useState(new Date()); +export const Range: Story = { + render: () => { + const [selected, setSelected] = useState({ + from: new Date(2025, 2, 1), + to: new Date(2025, 2, 3), + }); + const handleSelect: SelectRangeEventHandler = range => setSelected(range); return ( - + ); }, }; diff --git a/packages/ui/src/ui/calendar/Calendar.tsx b/packages/ui/src/ui/calendar/Calendar.tsx index 56885d48..dae2fabd 100644 --- a/packages/ui/src/ui/calendar/Calendar.tsx +++ b/packages/ui/src/ui/calendar/Calendar.tsx @@ -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) => 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(false); - return ( - - - - - - - - - - - - ); -}; - -const SingleDayPickCalendar = ({ - selected, - onSelect, - setCalendarOpen, - fromDate, -}: CalendarProps) => { +export const Calendar = ({className, ...props}: DayPickerProps) => { return ( { - 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} /> ); }; diff --git a/packages/ui/src/ui/popover/Popover.mdx b/packages/ui/src/ui/popover/Popover.mdx new file mode 100644 index 00000000..d07b4299 --- /dev/null +++ b/packages/ui/src/ui/popover/Popover.mdx @@ -0,0 +1,105 @@ +{/* Popover.mdx */} + +import * as stories from './Popover.stories'; +import {Canvas, Meta, Primary, Controls, Description} from '@storybook/blocks'; + + + +# Popover + +`Popover`는 합성 컴포넌트입니다. 버튼 클릭 시 추가적인 정보를 보여주는 데 사용됩니다. + +- [Overview](#overview) +- [Props](#props) +- [Description](#description) + +## Overview + + + +## Props + + + +## 컴포넌트 구조 + +`Popover`는 다음과 같은 하위 컴포넌트들로 구성됩니다: + +- `Popover`: 최상위 컴포넌트 +- `PopoverTrigger`: Popover를 열고 닫는 트리거 컴포넌트 +- `PopoverContent`: Popover의 내용을 표시하는 컴포넌트 +- `PopoverAnchor`: Popover의 위치를 지정하는 기준점 컴포넌트 + +## PopoverContent Props + +PopoverContent 컴포넌트는 다음과 같은 props를 받습니다: + +- `align`: Popover content의 정렬 방향 ('start' | 'center' | 'end', 기본값: 'center') +- `sideOffset`: Trigger와 content 사이의 거리 (number, 기본값: 6) +- `className`: 커스텀 스타일링을 위한 클래스 (string) + +## 스타일링 + +Popover는 기본적으로 다음과 같은 스타일이 적용됩니다: + +- z-index: 50 +- 최소 너비: 트리거 요소의 너비 +- 둥근 모서리 +- 테두리 +- 그림자 효과 +- 애니메이션 효과 (열기/닫기) + +이러한 스타일은 `className` prop을 통해 커스터마이즈할 수 있습니다. + +## usePopover Hook + +`usePopover` 훅을 사용하면 Popover의 상태를 더 쉽게 관리할 수 있습니다. + +### 사용 예시 + +```tsx +import {PopoverProvider, usePopover} from './use-popover'; + +const MyComponent = () => { + const {openPopover, closePopover} = usePopover(); + + return ( + + , + ) + } + > + Open Popover + + ); +}; + +// 최상위 컴포넌트에서 Provider로 감싸기 +const App = () => ( + + + +); +``` + +### Hook API + +usePopover 훅은 다음과 같은 값들을 반환합니다: + +- `content`: 현재 Popover에 표시되는 컨텐츠 (ReactNode) +- `openPopover`: Popover를 열고 컨텐츠를 설정하는 함수 ((content: ReactNode) => void) +- `closePopover`: Popover를 닫는 함수 (() => void) + +## 접근성 + +Popover는 Radix UI의 Popover 컴포넌트를 기반으로 하며, 다음과 같은 접근성 기능을 제공합니다: + +- 키보드 네비게이션 지원 +- ARIA 속성 자동 관리 +- 포커스 관리 +- ESC 키를 통한 닫기 diff --git a/packages/ui/src/ui/popover/Popover.stories.tsx b/packages/ui/src/ui/popover/Popover.stories.tsx new file mode 100644 index 00000000..ddf133e9 --- /dev/null +++ b/packages/ui/src/ui/popover/Popover.stories.tsx @@ -0,0 +1,101 @@ +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, +} from './Popover'; +import {PopoverProvider, usePopover} from './use-popover'; +import type {Meta, StoryObj} from '@storybook/react'; + +const meta: Meta = { + title: 'Components/Popover', + argTypes: { + align: { + control: 'select', + options: ['start', 'center', 'end'], + description: 'Popover content alignment', + }, + sideOffset: { + control: 'number', + description: 'Distance between trigger and content', + }, + className: { + control: 'text', + description: 'Custom class for styling', + }, + }, +}; + +export default meta; + +export const Default: StoryObj = { + render: args => ( + + + Open Popover + + Popover Content + + ), +}; + +export const WithAnchor: StoryObj = { + render: () => ( + + + + Open Popover + + + Popover Content with Anchor + + + ), +}; + +const PopoverWithHook = () => { + const {openPopover, closePopover} = usePopover(); + + return ( +
+ +
, + ) + } + > + Open Popover with Hook + + + , + ) + } + > + Open Another Popover + + + ); +}; + +export const WithHook: StoryObj = { + render: () => ( + + + + ), +}; diff --git a/packages/ui/src/ui/popover/Popover.tsx b/packages/ui/src/ui/popover/Popover.tsx new file mode 100644 index 00000000..900148cc --- /dev/null +++ b/packages/ui/src/ui/popover/Popover.tsx @@ -0,0 +1,34 @@ +'use client'; + +import clsx from 'clsx'; +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, align = 'center', sideOffset = 6, ...props}, ref) => ( + + + {props.children} + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export {Popover, PopoverTrigger, PopoverContent, PopoverAnchor}; diff --git a/packages/ui/src/ui/popover/use-popover.tsx b/packages/ui/src/ui/popover/use-popover.tsx new file mode 100644 index 00000000..fa03f152 --- /dev/null +++ b/packages/ui/src/ui/popover/use-popover.tsx @@ -0,0 +1,47 @@ +import {Popover, PopoverContent, PopoverTrigger} from './Popover'; +import React, {useState} from 'react'; + +const PopoverContext = React.createContext<{ + content: React.ReactNode; + openPopover: (content: React.ReactNode) => void; + closePopover: () => void; +}>({ + content: null, + openPopover: () => {}, + closePopover: () => {}, +}); + +interface PopoverProviderProps { + children: React.ReactNode; +} + +export const PopoverProvider = ({children}: PopoverProviderProps) => { + const [content, setContent] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + const openPopover = (newContent: React.ReactNode) => { + setContent(newContent); + setIsOpen(true); + }; + + const closePopover = () => { + setIsOpen(false); + }; + + return ( + + + {children} + {content} + + + ); +}; + +export const usePopover = () => { + const context = React.useContext(PopoverContext); + if (!context) { + throw new Error('usePopover must be used within a PopoverProvider'); + } + return context; +}; diff --git a/packages/ui/src/ui/timePicker/TimePicker.css b/packages/ui/src/ui/timePicker/TimePicker.css new file mode 100644 index 00000000..2cab5054 --- /dev/null +++ b/packages/ui/src/ui/timePicker/TimePicker.css @@ -0,0 +1,102 @@ +.ios-style-picker { + position: relative; + height: 100%; + text-align: center; + overflow: hidden; + font-family: 'Apple SD Gothic Neo'; +} +.ios-style-picker ul { + margin: 0; + padding: 0; + list-style: none; +} +.ios-style-picker:before, +.ios-style-picker:after { + position: absolute; + z-index: 1; + display: block; + content: ''; + width: 100%; + height: 50%; +} +.ios-style-picker:before { + top: 0; + background-image: linear-gradient( + to bottom, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0) + ); +} +.ios-style-picker:after { + bottom: 0; + background-image: linear-gradient( + to top, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0) + ); +} +.ios-style-picker__option-list { + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 0; + transform-style: preserve-3d; + margin: 0 auto; + display: block; + transform: translateZ(-150px) rotateX(0deg); + -webkit-font-smoothing: subpixel-antialiased; + color: #666; +} +.ios-style-picker__option-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 80px; + -webkit-font-smoothing: subpixel-antialiased; +} +.ios-style-picker__highlight { + position: absolute; + top: 50%; + + width: 100%; + + transform: translate(0, -50%); + background-color: white; + overflow: hidden; +} +.ios-style-picker__highlight-list { + position: absolute; + width: 100%; + margin: 0; + padding: 0; +} + +/* DatePicker */ + +.react-ios-style-time-picker { + display: flex; + align-items: stretch; + justify-content: space-between; + width: 100%; + height: 150px; +} + +.react-ios-style-time-picker > div { + flex: 1; +} + +.react-ios-style-time-picker .ios-style-picker { + font-size: 16px; + font-weight: 400; + line-height: 24px; + color: #7a8190; +} + +.react-ios-style-time-picker .ios-style-picker__highlight { + font-size: 18px; + font-weight: 600; + line-height: 29px; + color: #495460; +} diff --git a/packages/ui/src/ui/timePicker/TimePicker.mdx b/packages/ui/src/ui/timePicker/TimePicker.mdx new file mode 100644 index 00000000..d18e3628 --- /dev/null +++ b/packages/ui/src/ui/timePicker/TimePicker.mdx @@ -0,0 +1,32 @@ +{/* TimePicker.mdx */} + +import * as stories from './TimePicker.stories'; +import {Canvas, Meta, Primary, Controls, Description} from '@storybook/blocks'; + + + +# TimePicker + +`TimePicker`는 사용자가 시간을 선택할 수 있도록 돕는 컴포넌트입니다. 12시간 또는 24시간 형식을 지원하며, 직관적인 스와이프 방식으로 시간을 선택할 수 있도록 구현되어 있습니다. 이 컴포넌트는 모바일 환경에서도 잘 작동하며, 다양한 사용 사례에 맞게 쉽게 커스터마이징할 수 있습니다. + +--- + +- [Overview](#overview) +- [Props](#props) +- [Description](#description) + +## Overview + + + +TimePicker 컴포넌트는 시간을 선택하는 UI를 제공합니다. 사용자에게 시간을 쉽게 설정할 수 있는 방법을 제공하며, 12시간과 24시간 형식 모두 지원합니다. `infinite` 모드를 통해 무한 스크롤로 시간을 빠르게 선택할 수 있습니다. 또한 `initTime` prop을 통해 초기 시간을 설정할 수 있습니다. + +## Props + + + +## Usage + +### 무한스크롤 + + diff --git a/packages/ui/src/ui/timePicker/TimePicker.stories.tsx b/packages/ui/src/ui/timePicker/TimePicker.stories.tsx new file mode 100644 index 00000000..b5f1f06e --- /dev/null +++ b/packages/ui/src/ui/timePicker/TimePicker.stories.tsx @@ -0,0 +1,69 @@ +import type {TimePickerProps} from './TimePicker'; +import {TimePicker} from './TimePicker'; +import {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; + +const meta: Meta = { + title: 'Components/TimePicker', + component: TimePicker, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: '12hours format', + render: (args: TimePickerProps) => , +}; + +export const DefaultInfinite: Story = { + name: '12 hours format with infinite', + render: (args: TimePickerProps) => , + args: { + infinite: true, + }, +}; + +export const format24: Story = { + name: '24 hours format ', + render: (args: TimePickerProps) => , + args: { + hourFormat: '24', + }, +}; + +export const format24infinite: Story = { + name: '24 hours format with infinite', + render: (args: TimePickerProps) => , + args: { + hourFormat: '24', + infinite: true, + }, +}; + +export const TimePickerwithState: Story = { + name: 'TimePicker with State example', + render: () => { + const [time, setTime] = useState<{hour: number; minute: number}>({ + hour: new Date().getHours(), + minute: new Date().getMinutes(), + }); + + const handleTimeChange = (hour: number, minute: number) => { + setTime({hour, minute}); + }; + return ( +
+

+ 선택된 시간: {time.hour}:{time.minute} +

+ +
+ ); + }, +}; diff --git a/packages/ui/src/ui/timePicker/TimePicker.tsx b/packages/ui/src/ui/timePicker/TimePicker.tsx new file mode 100644 index 00000000..6a30f3e1 --- /dev/null +++ b/packages/ui/src/ui/timePicker/TimePicker.tsx @@ -0,0 +1,183 @@ +import './TimePicker.css'; +import type {LOCALE_MAP} from './TimePickerSource'; +import TimePickerSource from './TimePickerSource'; +import IosStylePicker from './utils/IosStylePicker'; +import {useEffect, useRef} from 'react'; + +const ONCHANGE_TIMEOUT_DELAY = 100; + +export type TimePickerProps = { + onChange: (hour: number, minute: number) => void; + initTime?: Date; + infinite?: boolean; + className?: string; + hourFormat?: '12' | '24'; + locale?: keyof typeof LOCALE_MAP; +}; + +type TimePickerStateRef = { + currentHour: number; + currentMinute: number; + currentAmPm: number; + source: TimePickerSource; + onChange: TimePickerProps['onChange']; + onChangeTimeout: NodeJS.Timeout | null; +}; + +export const TimePicker = ({ + onChange, + initTime: _initTime = new Date(), + infinite = false, + className: _className, + hourFormat = '12', + locale = 'en', +}: TimePickerProps) => { + const className = + 'react-ios-style-time-picker' + (_className ? ` ${_className}` : ''); + + const ref = useRef({ + currentHour: + hourFormat === '12' && !infinite + ? _initTime.getHours() % 12 || 12 + : _initTime.getHours(), + currentMinute: _initTime.getMinutes(), + currentAmPm: _initTime.getHours() < 12 ? 1 : 2, + source: new TimePickerSource({ + hourFormat: hourFormat, + infinite: infinite, + locale: locale, + }), + onChange, + onChangeTimeout: null, + }).current; + + const ampmPickerRef = useRef(null); + const hourPickerRef = useRef(null); + const minutePickerRef = useRef(null); + + useEffect(() => { + ref.onChange = onChange; + }, [onChange]); + + useEffect(() => { + const onChange = () => { + if (ref.onChangeTimeout) { + clearTimeout(ref.onChangeTimeout); + } + ref.onChangeTimeout = setTimeout(() => { + ref.onChange(ref.currentHour, ref.currentMinute); + ref.onChangeTimeout = null; + }, ONCHANGE_TIMEOUT_DELAY); + }; + + const updateHourSource = () => { + hourSelector.selectByCurrentHour(ref.currentHour); + }; + + const ampmSelector = + hourFormat === '12' && + new IosStylePicker(ampmPickerRef.current!, { + variant: 'normal', + source: ref.source.ampm, + currentData: ref.currentAmPm, + onChange: selected => { + const changed = ref.currentAmPm !== selected.value; + + if (hourFormat === '12') { + if (selected.value === 1) { + ref.currentHour = ref.currentHour % 12; + } else if (selected.value === 2) { + ref.currentHour = (ref.currentHour % 12) + 12; + } + ref.currentAmPm = selected.value; + } + + if (changed) { + if (infinite === true) { + updateHourSource(); + } + onChange(); + } + }, + }); + + const hourSelector = new IosStylePicker(hourPickerRef.current!, { + variant: infinite ? 'infinite' : 'normal', + source: ref.source.hours, + onChange: selected => { + const changed = ref.currentHour !== selected.value; + + if (ref.currentAmPm === 2 && selected.value < 12) { + ref.currentHour = selected.value + 12; + } else if (ref.currentAmPm === 1 && selected.value === 12) { + ref.currentHour = 0; + } else { + ref.currentHour = selected.value; + } + if (changed) { + onChange(); + } + }, + onSelect: currentIndex => { + if (infinite === true && hourFormat === '12') { + if (currentIndex.value < 12) { + if (ampmSelector) { + ampmSelector.updateAmPm(1); + ref.currentAmPm = 1; + } + } else if (currentIndex.value >= 12) { + if (ampmSelector) { + ampmSelector.updateAmPm(2); + ref.currentAmPm = 2; + } + } + } + }, + }); + + const minuteSelector = new IosStylePicker(minutePickerRef.current!, { + variant: infinite ? 'infinite' : 'normal', + source: ref.source.minutes, + onChange: selected => { + const changed = ref.currentMinute !== selected.value; + ref.currentMinute = selected.value; + + if (changed) { + onChange(); + } + }, + }); + + setTimeout(() => { + const initHour = + hourFormat === '12' && !infinite + ? _initTime.getHours() % 12 || 12 + : _initTime.getHours(); + const initMinute = _initTime.getMinutes(); + const initAmPm = _initTime.getHours() < 12 ? 1 : 2; + + if (hourFormat === '12' && ampmSelector) { + ampmSelector.select(initAmPm); + } + + hourSelector.select(initHour); + minuteSelector.select(initMinute); + }, 0); + + return () => { + if (ampmSelector) { + ampmSelector.destroy(); + } + hourSelector.destroy(); + minuteSelector.destroy(); + }; + }, [infinite, hourFormat]); + + return ( +
+ {hourFormat === '12' &&
} +
+
+
+ ); +}; diff --git a/packages/ui/src/ui/timePicker/TimePickerSource.ts b/packages/ui/src/ui/timePicker/TimePickerSource.ts new file mode 100644 index 00000000..1317ba46 --- /dev/null +++ b/packages/ui/src/ui/timePicker/TimePickerSource.ts @@ -0,0 +1,91 @@ +export const LOCALE_MAP = { + en: {AM: 'AM', PM: 'PM'}, + ko: {AM: '오전', PM: '오후'}, + ja: {AM: '午前', PM: '午後'}, + zh: {AM: '上午', PM: '下午'}, +}; + +type TimePickerSourceItem = { + value: number; + text: string; +}; + +export type TimePickerSourceOptions = { + hourFormat?: '12' | '24'; + infinite?: boolean; + locale?: keyof typeof LOCALE_MAP; +}; + +class TimePickerSource { + #ampm: TimePickerSourceItem[]; + #hours: TimePickerSourceItem[]; + #minutes: TimePickerSourceItem[]; + #hourFormat: '12' | '24'; + #infinite: boolean; + + constructor({ + hourFormat, + infinite, + locale = 'en', + }: TimePickerSourceOptions = {}) { + this.#hourFormat = hourFormat ?? '12'; + this.#infinite = infinite ?? false; + this.#ampm = [ + {value: 1, text: LOCALE_MAP[locale].AM}, + {value: 2, text: LOCALE_MAP[locale].PM}, + ]; + + this.#hours = this.#getHours(); + this.#minutes = this.#getMinutes(); + } + + #adjustTextFor12Hour(value: number): string { + if (value === 0) { + return '12'; + } + if (value > 12) { + return String(value - 12); + } + return String(value); + } + + #getItems(from: number, to: number, is12HourFormat: boolean = false) { + return Array.from({length: to - from + 1}, (_, i) => { + const value = i + from; + const text = is12HourFormat + ? this.#adjustTextFor12Hour(value) + : String(value).padStart(2, '0'); + return {value, text}; + }); + } + + #getHours() { + const is12HourFormat = this.#hourFormat === '12'; + if (this.#infinite) { + return this.#getItems(0, 23, is12HourFormat); + } + return this.#getItems( + is12HourFormat ? 1 : 0, + is12HourFormat ? 12 : 23, + is12HourFormat, + ); + } + + #getMinutes() { + return this.#getItems(0, 59); + } + + get ampm() { + return this.#ampm; + } + + get hours() { + return this.#hours; + } + + get minutes() { + return this.#minutes; + } +} + +export default TimePickerSource; diff --git a/packages/ui/src/ui/timePicker/utils/IosStylePicker.ts b/packages/ui/src/ui/timePicker/utils/IosStylePicker.ts new file mode 100644 index 00000000..596b6ba3 --- /dev/null +++ b/packages/ui/src/ui/timePicker/utils/IosStylePicker.ts @@ -0,0 +1,372 @@ +import IosStylePickerHtml from './IosStylePickerHtml'; +import easing from './easing'; +import normalize from './normalize'; + +type IosStylePickerVariant = 'infinite' | 'normal'; +export interface IosStylePickerSourceItem { + value: number; + text: string; +} + +interface IosStylePickerTouchData { + startY: number; + yArr: [number, number][]; + touchScroll: number; +} +type IosStylePickerUserEvent = MouseEvent | TouchEvent; + +export interface IosStylePickerOptions { + variant?: IosStylePickerVariant; + source: IosStylePickerSourceItem[]; + onChange?: (selected: IosStylePickerSourceItem) => void; + onSelect?: (selected: IosStylePickerSourceItem) => void; + count?: number; + sensitivity?: number; + value?: number; + currentData?: number; +} + +class IosStylePicker { + private variant: IosStylePickerVariant; + private source: IosStylePickerSourceItem[]; + private selected: {value: number; text: string}; + private currentData: number; + + private onChange?: (selected: IosStylePickerSourceItem) => void; + private onSelect?: (selected: IosStylePickerSourceItem) => void; + + private sensitivity: number; + private wheelCount: number; + private exceedA: number; + private moveT: number; + private moving: boolean; + + private targetElement: HTMLElement; + private html: IosStylePickerHtml | undefined; + + private events: { + touchstart: (evt: IosStylePickerUserEvent) => void; + touchmove: (evt: IosStylePickerUserEvent) => void; + touchend: (evt: IosStylePickerUserEvent) => void; + }; + + private itemHeight: number; + private itemAngle: number; + private radius: number; + private scroll: number; + + private touchData: IosStylePickerTouchData = { + startY: 0, + yArr: [], + touchScroll: 0, + }; + + constructor(targetElement: HTMLElement, options: IosStylePickerOptions) { + this.variant = options.variant ?? 'infinite'; + this.source = options.source; + this.currentData = + typeof options.currentData === 'number' ? options.currentData - 1 : 0; + + this.selected = this.source[this.currentData]; + + this.onChange = options.onChange; + this.onSelect = options.onSelect; + + const count = options.count ?? 20; + this.wheelCount = count - (count % 4); // 4의 배수여야 함 + this.sensitivity = options.sensitivity ?? 8; + + this.exceedA = 10; + this.moveT = 0; + this.moving = false; + + this.targetElement = targetElement; + if (!this.targetElement) { + throw new Error(`targetElement does not exists.`); + } + + this.itemHeight = (this.targetElement.offsetHeight * 3) / count; + this.itemAngle = 360 / count; // 아이템 간 각도 차이 + this.radius = this.itemHeight / Math.tan((this.itemAngle * Math.PI) / 180); // 반지름 + + this.scroll = 0; + this._create(this.source); + + this.events = { + touchstart: this._createEventListener('touchstart'), + touchmove: this._createEventListener('touchmove'), + touchend: this._createEventListener('touchend'), + }; + + this.html?.addEventListener('touchstart', this.events.touchstart); + document.addEventListener('mousedown', this.events.touchstart); + this.html?.addEventListener('touchend', this.events.touchend); + document.addEventListener('mouseup', this.events.touchend); + + if (this.source.length) { + this.select(this.selected.value); + } + } + + private _createEventListener( + eventName: 'touchstart' | 'touchmove' | 'touchend', + ) { + return (evt: IosStylePickerUserEvent) => { + if (!this.html?.equalOrContains(evt.target)) { + return; + } + if (this.source.length === 0) { + return; + } + evt.preventDefault(); + this[`_${eventName}`](evt); + }; + } + + private _touchstart(evt: IosStylePickerUserEvent) { + if (!this.html) { + throw new Error('this.html does not exists'); + } + + this.html.addEventListener('touchmove', this.events.touchmove); + document.addEventListener('mousemove', this.events.touchmove); + + const eventY = + (evt as MouseEvent).clientY ?? (evt as TouchEvent).touches[0].clientY; + this.touchData.startY = eventY; + this.touchData.yArr = [[eventY, new Date().getTime()]]; + this.touchData.touchScroll = this.scroll; + this._stop(); + } + + private _touchmove(evt: IosStylePickerUserEvent) { + const eventY = + (evt as MouseEvent).clientY ?? (evt as TouchEvent).touches[0].clientY; + this.touchData.yArr.push([eventY, new Date().getTime()]); + if (this.touchData.yArr.length > 5) { + this.touchData.yArr.unshift(); + } + + const scrollAdd = (this.touchData.startY - eventY) / this.itemHeight; + const baseMoveToControl = scrollAdd + this.scroll; + const moveToScroll = + this.variant === 'infinite' + ? normalize(baseMoveToControl, this.source.length) + : baseMoveToControl < 0 + ? baseMoveToControl * 0.3 // 무한 스크롤이 아니면 스크롤이 좀 덜 되게 조정 + : baseMoveToControl > this.source.length + ? this.source.length + + (baseMoveToControl - this.source.length) * 0.3 // 무한 스크롤이 아니면 스크롤이 좀 덜 되게 조정 + : baseMoveToControl; + + this.touchData.touchScroll = this._moveTo(moveToScroll); + } + + private _getInitV() { + if (this.touchData.yArr.length === 1) { + return 0; + } + + const startTime = this.touchData.yArr[this.touchData.yArr.length - 2][1]; + const endTime = this.touchData.yArr[this.touchData.yArr.length - 1][1]; + const startY = this.touchData.yArr[this.touchData.yArr.length - 2][0]; + const endY = this.touchData.yArr[this.touchData.yArr.length - 1][0]; + + const v = + (((startY - endY) / this.itemHeight) * 1000) / (endTime - startTime); + const sign = v > 0 ? 1 : -1; + + return Math.abs(v) > 30 ? 30 * sign : v; + } + + private _touchend(_evt: IosStylePickerUserEvent) { + if (!this.html) { + throw new Error('this.html does not exists.'); + } + + this.html.removeEventListener('touchmove', this.events.touchmove); + document.removeEventListener('mousemove', this.events.touchmove); + + const v = this._getInitV(/*touchData*/); + + this.scroll = this.touchData.touchScroll; + this._animateMoveByInitV(v); + } + + private _create(source: IosStylePickerSourceItem[]) { + if (!source.length) { + throw new Error('source does not exists.'); + } + if (this.variant === 'infinite') { + let concatSource: IosStylePickerSourceItem[] = [...source]; + while (concatSource.length < this.wheelCount / 2) { + concatSource = concatSource.concat(source); + } + source = concatSource; + } + this.source = source; + + this.html = new IosStylePickerHtml({ + container: this.targetElement, + source: this.source, + isInfinite: this.variant === 'infinite', + wheelCount: this.wheelCount, + itemAngle: this.itemAngle, + itemHeight: this.itemHeight, + radius: this.radius, + }); + } + + private _moveTo(scroll: number) { + if (!this.html) throw new Error('html does not exists'); + + if (this.variant === 'infinite') { + scroll = normalize(scroll, this.source.length); + } + + this.html.scroll(scroll); + if (this.onSelect) { + const currentScroll = + Math.round(scroll) > this.source.length - 1 + ? this.source.length - 1 + : Math.round(scroll); + + this.onSelect(this.source[Math.round(currentScroll)]); + } + + return scroll; + } + + async _animateMoveByInitV(initV: number) { + if (this.variant === 'infinite') { + const a = initV > 0 ? -this.sensitivity : this.sensitivity; + const t = Math.abs(initV / a); + const totalScrollLen = initV * t + (a * t * t) / 2; + const finalScroll = Math.round(this.scroll + totalScrollLen); + + await this._animateToScroll(this.scroll, finalScroll, t, 'easeOutQuart'); + } else if (this.scroll < 0 || this.scroll > this.source.length - 1) { + const finalScroll = this.scroll < 0 ? 0 : this.source.length - 1; + + await this._animateToScroll( + this.scroll, + finalScroll, + Math.sqrt(Math.abs((this.scroll - finalScroll) / this.exceedA)), + ); + } else { + const a = initV > 0 ? -this.sensitivity : this.sensitivity; + let t = Math.abs(initV / a); + let totalScrollLen = initV * t + (a * t * t) / 2; + let finalScroll = Math.round(this.scroll + totalScrollLen); + finalScroll = + finalScroll < 0 + ? 0 + : finalScroll > this.source.length - 1 + ? this.source.length - 1 + : finalScroll; + + totalScrollLen = finalScroll - this.scroll; + t = Math.sqrt(Math.abs(totalScrollLen / a)); + await this._animateToScroll(this.scroll, finalScroll, t, 'easeOutQuart'); + } + this._selectByScroll(this.scroll); + } + + _animateToScroll( + initScroll: number, + finalScroll: number, + t: number, + easingName: keyof typeof easing = 'easeOutQuart', + ) { + if (initScroll === finalScroll || t === 0) { + this._moveTo(initScroll); + return; + } + + const start = new Date().getTime() / 1000; + const totalScrollLen = finalScroll - initScroll; + + return new Promise((resolve, reject) => { + this.moving = true; + const tick = () => { + const pass = new Date().getTime() / 1000 - start; + + if (pass < t) { + this.scroll = this._moveTo( + initScroll + easing[easingName](pass / t) * totalScrollLen, + ); + this.moveT = requestAnimationFrame(tick); + } else { + resolve(); + this._stop(); + this.scroll = this._moveTo(initScroll + totalScrollLen); + } + }; + tick(); + }); + } + + private _stop() { + this.moving = false; + cancelAnimationFrame(this.moveT); + } + + private _selectByScroll(scroll: number) { + scroll = normalize(scroll, this.source.length) | 0; + if (scroll > this.source.length - 1) { + scroll = this.source.length - 1; + this._moveTo(scroll); + } + this._moveTo(scroll); + this.scroll = scroll; + this.selected = this.source[scroll]; + this.onChange && this.onChange(this.selected); + } + + selectByCurrentHour(currentHour: number) { + if (!this.moving) { + this._selectByScroll(currentHour); + } + } + + select(value: number) { + for (let i = 0; i < this.source.length; i++) { + if (this.source[i].value === value) { + window.cancelAnimationFrame(this.moveT); + const finalScroll = i; + this._selectByScroll(finalScroll); + return; + } + } + throw new Error(`can't find value: ${value}`); + } + + async updateAmPm(value: number) { + const index = this.source.findIndex(item => item.value === value); + if (index === -1) { + throw new Error(`Can't find value: ${value}`); + } + const initScroll = this.scroll; + const finalScroll = index; + + const t = Math.sqrt( + Math.abs((finalScroll - initScroll) / this.sensitivity), + ); + await this._animateToScroll(initScroll, finalScroll, t); + } + + destroy() { + this._stop(); + + this.html?.removeEventListener('touchstart', this.events.touchstart); + this.html?.removeEventListener('touchmove', this.events.touchmove); + this.html?.removeEventListener('touchend', this.events.touchend); + document.removeEventListener('mousedown', this.events.touchstart); + document.removeEventListener('mousemove', this.events.touchmove); + document.removeEventListener('mouseup', this.events.touchend); + + this.html?.clear(); + } +} + +export default IosStylePicker; diff --git a/packages/ui/src/ui/timePicker/utils/IosStylePickerHtml.ts b/packages/ui/src/ui/timePicker/utils/IosStylePickerHtml.ts new file mode 100644 index 00000000..6d26a62a --- /dev/null +++ b/packages/ui/src/ui/timePicker/utils/IosStylePickerHtml.ts @@ -0,0 +1,205 @@ +import getRange from './getRange'; + +const classNames = { + wrapper: 'ios-style-picker', + optionList: 'ios-style-picker__option-list', + optionItem: 'ios-style-picker__option-item', + highlight: 'ios-style-picker__highlight', + highlightList: 'ios-style-picker__highlight-list', + highlightItem: 'ios-style-picker__highlight-item', +}; + +type IosStylePickerHtmlOptions = { + container: HTMLElement; + isInfinite: boolean; + wheelCount: number; + source: {text: string}[]; + itemAngle: number; + itemHeight: number; + radius: number; +}; +class IosStylePickerHtml { + private _container: HTMLElement; + private _optionList: HTMLElement; + private _optionItems: NodeListOf; + private _highlightList: HTMLElement; + + private _source: {text: string}[]; + private _isInfinite: boolean; + private wheelCount: number; + private itemAngle: number; + private itemHeight: number; + private radius: number; + + constructor({ + container, + source, + isInfinite, + wheelCount, + itemAngle, + itemHeight, + radius, + }: IosStylePickerHtmlOptions) { + this._container = container; + + this._source = source; + this._isInfinite = isInfinite; + this.wheelCount = wheelCount; + this.itemAngle = itemAngle; + this.itemHeight = itemHeight; + this.radius = radius; + + const optionListHtml = this._getOptionItems(); + + const highListHtml = this._getHighlightItems(); + + this._container.innerHTML = ` +
+
    + ${optionListHtml} +
+
+
    + ${highListHtml} +
+
+
`; + + const optionList = this._container.querySelector( + `.${classNames.optionList}`, + ); + if (!optionList) { + throw new Error('optionList does not exists'); + } + this._optionList = optionList; + + const optionsItems = this._container.querySelectorAll( + `.${classNames.optionItem}`, + ); + if (!optionsItems) { + throw new Error('optionList does not exists'); + } + this._optionItems = optionsItems; + + const highlightList = this._container.querySelector( + `.${classNames.highlightList}`, + ); + if (!highlightList) { + throw new Error(`highlightList does not exists.`); + } + if (isInfinite) { + highlightList.style.top = `${-this.itemHeight}px`; + } + this._highlightList = highlightList; + } + + _getOptionItems() { + const optionIndices = this._isInfinite + ? getRange( + -this.wheelCount / 4, + this._source.length + this.wheelCount / 4, + ) + : getRange(0, this._source.length); + + const optionListItems = optionIndices.map(i => ({ + rotateX: -this.itemAngle * i, + index: i, + text: this._source[(i + this._source.length) % this._source.length].text, + })); + + return optionListItems.reduce( + (acc, item) => + `${acc} +
  • + ${item.text} +
  • `, + '', + ); + } + + _getHighlightItems() { + const indices = this._isInfinite + ? getRange(-1, this._source.length + 1) + : getRange(0, this._source.length); + + const items = indices.map(i => ({ + text: this._source[(i + this._source.length) % this._source.length].text, + })); + + return items.reduce( + (acc, item) => + `${acc} +
  • + ${item.text} +
  • `, + '', + ); + } + + scroll(scrollCount: number) { + const dz = -this.radius; + const rotateX = this.itemAngle * scrollCount; + this._optionList.style.transform = `translate3d(0, 0, ${dz}px) rotateX(${rotateX}deg)`; + + const dy = -scrollCount * this.itemHeight; + this._highlightList.style.transform = `translate3d(0, ${dy}px, 0)`; + + [...this._optionItems].forEach(itemElem => { + if (itemElem.dataset.index === undefined) { + throw new Error('itemElem.dataset.index does not exists'); + } + itemElem.style.visibility = + Math.abs(+itemElem.dataset.index - scrollCount) > this.wheelCount / 4 + ? 'hidden' + : 'visible'; + }); + } + + addEventListener( + eventName: 'touchstart' | 'touchmove' | 'touchend', + listener: (evt: TouchEvent) => any, + ) { + this._container.addEventListener(eventName, listener); + } + + removeEventListener( + eventName: 'touchstart' | 'touchmove' | 'touchend', + listener: (evt: TouchEvent) => any, + ) { + this._container.removeEventListener(eventName, listener); + } + + equalOrContains(target: EventTarget | null) { + return ( + this._container?.contains(target as Node) || this._container === target + ); + } + + clear() { + this._container.innerHTML = ''; + } + + get container() { + return this._container; + } +} + +export default IosStylePickerHtml; diff --git a/packages/ui/src/ui/timePicker/utils/easing.ts b/packages/ui/src/ui/timePicker/utils/easing.ts new file mode 100644 index 00000000..bc974a52 --- /dev/null +++ b/packages/ui/src/ui/timePicker/utils/easing.ts @@ -0,0 +1,10 @@ +const easing = { + easeOutCubic: function (pos: number) { + return Math.pow(pos - 1, 3) + 1; + }, + easeOutQuart: function (pos: number) { + return -(Math.pow(pos - 1, 4) - 1); + }, +}; + +export default easing; diff --git a/packages/ui/src/ui/timePicker/utils/getRange.ts b/packages/ui/src/ui/timePicker/utils/getRange.ts new file mode 100644 index 00000000..80746722 --- /dev/null +++ b/packages/ui/src/ui/timePicker/utils/getRange.ts @@ -0,0 +1,5 @@ +function getRange(start: number, end: number) { + return [...new Array(end - start)].map((_, i) => start + i); +} + +export default getRange; diff --git a/packages/ui/src/ui/timePicker/utils/normalize.ts b/packages/ui/src/ui/timePicker/utils/normalize.ts new file mode 100644 index 00000000..a2dee43c --- /dev/null +++ b/packages/ui/src/ui/timePicker/utils/normalize.ts @@ -0,0 +1,9 @@ +function normalize(value: number, size: number) { + let normalized = value; + while (normalized < 0) { + normalized += size; + } + return normalized % size; +} + +export default normalize;