diff --git a/src/components/DatePicker/DatePicker.stories.tsx b/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000..8c2d58f --- /dev/null +++ b/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,406 @@ +import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date'; +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; +import { + Calendar, + CalendarCell, + CalendarGrid, + CalendarGridBody, + CalendarGridHeader, + CalendarHeaderCell, + DatePicker as ReactAriaDatePicker, + Dialog, + Popover, + type DateValue, +} from 'react-aria-components'; +import { + Button, + DatePicker, + DatePickerCalendarButton, + DatePickerDate, + DatePickerMonth, + DatePickerYear, + ErrorText, + Legend, + RequirementBadge, + Select, + SupportText, +} from '..'; + +/** + * Defaultタイプのデートピッカー。 + * 左右キーで年月日フィールド間を移動する機能を持ちます。 + * + * SeparatedタイプはSeparatedDatePickerコンポーネントを参照してください。 + */ +const meta = { + title: 'Component/DADS v2/DatePicker/DatePicker', + tags: ['autodocs'], + component: DatePicker, + argTypes: { + size: { + type: 'string', + description: 'デートピッカーのサイズを以下から選択します。', + control: { type: 'radio' }, + options: ['lg', 'md', 'sm'], + table: { + defaultValue: { summary: 'lg' }, + type: { summary: "'lg' | 'md' | 'sm'" }, + }, + }, + isError: { + description: 'エラー状態であるかどうかを指定します。', + control: { type: 'boolean' }, + table: { + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + isDisabled: { + description: '無効状態であるかどうかを指定します。', + control: { type: 'boolean' }, + table: { + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + }, + args: { + size: 'lg', + isError: false, + isDisabled: false, + children: ({ yearRef, monthRef, dateRef }) => ( + <> + + + + + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** 項目ラベルやサポートテキスト等と一緒に表示した例 */ +export const WithFieldset: Story = { + render({ ...args }) { + return ( +
+ + 日付※必須 + + 例:2025年01月20日 + + {({ yearRef, monthRef, dateRef }) => ( + <> + + + + + )} + +
+ ); + }, +}; + +/** + * 項目ラベルやサポートテキスト等と一緒に表示した例 + * + * Defaultタイプはすべての入力フィールドがエラー状態になります。 + */ +export const Errored: Story = { + args: { + isError: true, + }, + render({ ...args }) { + return ( +
+ + 日付※必須 + + 例:2025年01月20日 + + {({ yearRef, monthRef, dateRef }) => ( + <> + + + + + )} + + *年を入力してください。 +
+ ); + }, +}; + +/** + * Disabled状態を表示した例 + */ +export const Disabled: Story = { + args: { + isDisabled: true, + }, + render({ ...args }) { + return ( +
+ + 日付※任意 + + 例:2025年01月20日 + + {({ yearRef, monthRef, dateRef }) => ( + <> + + + + + )} + +
+ ); + }, +}; + +/** カレンダーで日付を選択できるようにした例 */ +export const WithCalendar: Story = { + render({ size, ...args }) { + const [yearInput, setYearInput] = useState(''); + const [monthInput, setMonthInput] = useState(''); + const [dayInput, setDayInput] = useState(''); + + function handleCalendarChange(newDate: DateValue | null) { + if (newDate !== null) { + setYearInput(String(newDate.year)); + setMonthInput(String(newDate.month).padStart(2, '0')); + setDayInput(String(newDate.day).padStart(2, '0')); + } else { + setYearInput(''); + setMonthInput(''); + setDayInput(''); + } + } + + return ( + + {({ state }) => { + const updateCalendarDate = () => { + const year = Number.parseInt(yearInput); + const month = Number.parseInt(monthInput); + const day = Number.parseInt(dayInput); + + if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) { + state.setValue(new CalendarDate(year, month, day)); + } else { + state.setValue(null); + } + }; + + return ( + <> + + {({ yearRef, monthRef, dateRef }) => ( + <> + setYearInput(e.target.value)} + onBlur={updateCalendarDate} + /> + setMonthInput(e.target.value)} + onBlur={updateCalendarDate} + /> + setDayInput(e.target.value)} + onBlur={updateCalendarDate} + /> + + )} + + state.setOpen(true)} + /> + + + + {({ state: calendarState }) => ( + <> +
+ +
+ +

{calendarState.focusedDate.month}月

+ +
+
+ + + {(day) => ( + + {day} + + )} + + + {(date) => ( + + )} + + +
+ + +
+ + )} +
+
+
+ + ); + }} +
+ ); + }, +}; + +/** 年月または月日のみのタイプ */ +export const Partial: Story = { + render({ ...args }) { + return ( +
+ + {({ yearRef, monthRef }) => ( + <> + + + + )} + + + + {({ monthRef, dateRef }) => ( + <> + + + + )} + +
+ ); + }, +}; diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..fec648e --- /dev/null +++ b/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,77 @@ +import { type ComponentProps, type KeyboardEvent, type Ref, useRef } from 'react'; + +export type DatePickerSize = 'lg' | 'md' | 'sm'; + +export type DatePickerProps = Omit, 'children'> & { + size?: DatePickerSize; + isError?: boolean; + isDisabled?: boolean; + children: (props: { + yearRef: Ref; + monthRef: Ref; + dateRef: Ref; + }) => JSX.Element; +}; + +export const DatePicker = (props: DatePickerProps) => { + const { className, size = 'lg', isError, isDisabled, children, ...rest } = props; + + const yearRef = useRef(null); + const monthRef = useRef(null); + const dateRef = useRef(null); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowRight') { + moveRight(event); + } else if (event.key === 'ArrowLeft') { + moveLeft(event); + } else if (event.key.match(/^[^0-9]$/)) { + if (!event.ctrlKey && !event.metaKey) { + event.preventDefault(); + } + } + } + + function moveRight(event: KeyboardEvent) { + const input = event.target as HTMLInputElement; + if (input.selectionStart !== input.selectionEnd) { + return; + } + if (input.selectionEnd === input.value.length) { + event.preventDefault(); + if (input === yearRef.current) { + monthRef.current?.focus(); + } else if (input === monthRef.current) { + dateRef.current?.focus(); + } + } + } + + function moveLeft(event: KeyboardEvent) { + const input = event.target as HTMLInputElement; + if (input.selectionStart !== input.selectionEnd) { + return; + } + if (input.selectionStart === 0) { + event.preventDefault(); + if (input === monthRef.current) { + yearRef.current?.focus(); + } else if (input === dateRef.current) { + monthRef.current?.focus(); + } + } + } + + return ( +
+ {children({ yearRef, monthRef, dateRef })} +
+ ); +}; diff --git a/src/components/DatePicker/index.ts b/src/components/DatePicker/index.ts new file mode 100644 index 0000000..4344c21 --- /dev/null +++ b/src/components/DatePicker/index.ts @@ -0,0 +1,5 @@ +export * from './DatePicker'; +export * from './parts/DatePickerCalendarButton'; +export * from './parts/DatePickerDate'; +export * from './parts/DatePickerMonth'; +export * from './parts/DatePickerYear'; diff --git a/src/components/DatePicker/parts/DatePickerCalendarButton.tsx b/src/components/DatePicker/parts/DatePickerCalendarButton.tsx new file mode 100644 index 0000000..1c16eec --- /dev/null +++ b/src/components/DatePicker/parts/DatePickerCalendarButton.tsx @@ -0,0 +1,33 @@ +import { type ComponentProps, forwardRef } from 'react'; +import type { DatePickerSize } from '../DatePicker'; + +export type DatePickerCalendarButtonProps = ComponentProps<'button'> & { + size?: DatePickerSize; +}; + +export const DatePickerCalendarButton = forwardRef< + HTMLButtonElement, + DatePickerCalendarButtonProps +>((props, ref) => { + const { className, size = 'lg', ...rest } = props; + + return ( + + ); +}); diff --git a/src/components/DatePicker/parts/DatePickerDate.tsx b/src/components/DatePicker/parts/DatePickerDate.tsx new file mode 100644 index 0000000..a27bb9a --- /dev/null +++ b/src/components/DatePicker/parts/DatePickerDate.tsx @@ -0,0 +1,23 @@ +import { type ComponentProps, forwardRef } from 'react'; + +export type DatePickerDateProps = ComponentProps<'input'> & {}; + +export const DatePickerDate = forwardRef((props, ref) => { + const { className, 'aria-disabled': disabled, readOnly, ...rest } = props; + + return ( + + ); +}); diff --git a/src/components/DatePicker/parts/DatePickerMonth.tsx b/src/components/DatePicker/parts/DatePickerMonth.tsx new file mode 100644 index 0000000..b4b8c77 --- /dev/null +++ b/src/components/DatePicker/parts/DatePickerMonth.tsx @@ -0,0 +1,23 @@ +import { type ComponentProps, forwardRef } from 'react'; + +export type DatePickerMonthProps = ComponentProps<'input'> & {}; + +export const DatePickerMonth = forwardRef((props, ref) => { + const { className, 'aria-disabled': disabled, readOnly, ...rest } = props; + + return ( + + ); +}); diff --git a/src/components/DatePicker/parts/DatePickerYear.tsx b/src/components/DatePicker/parts/DatePickerYear.tsx new file mode 100644 index 0000000..b651774 --- /dev/null +++ b/src/components/DatePicker/parts/DatePickerYear.tsx @@ -0,0 +1,23 @@ +import { type ComponentProps, forwardRef } from 'react'; + +export type DatePickerYearProps = ComponentProps<'input'> & {}; + +export const DatePickerYear = forwardRef((props, ref) => { + const { className, 'aria-disabled': disabled, readOnly, ...rest } = props; + + return ( + + ); +}); diff --git a/src/components/Divider/Divider.tsx b/src/components/Divider/Divider.tsx index 30b20b1..d4e7137 100644 --- a/src/components/Divider/Divider.tsx +++ b/src/components/Divider/Divider.tsx @@ -2,12 +2,6 @@ import type { ComponentProps } from 'react'; export type DividerColor = 'gray-420' | 'gray-536' | 'black'; -export const DividerColorStyle: { [key in DividerColor]: string } = { - 'gray-420': 'border-solid-gray-420', - 'gray-536': 'border-solid-gray-536', - black: 'border-black', -}; - export type DividerProps = ComponentProps<'hr'> & { color?: DividerColor; }; @@ -15,5 +9,14 @@ export type DividerProps = ComponentProps<'hr'> & { export const Divider = (props: DividerProps) => { const { className, color = 'gray-420', ...rest } = props; - return
; + return ( +
+ ); }; diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 6029d35..b378613 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -2,15 +2,6 @@ import { type ComponentProps, forwardRef } from 'react'; export type InputBlockSize = 'lg' | 'md' | 'sm'; -export const InputBlockSizeStyle: { [key in InputBlockSize]: string } = { - // NOTE: - // Tailwind CSS (v3.4.4) does not have any utility classes for logical properties of sizing. - // Once it is officially released, we will replace them with classes like `bs-14`. - lg: 'h-14', - md: 'h-12', - sm: 'h-10', -}; - export type InputProps = ComponentProps<'input'> & { isError?: boolean; blockSize?: InputBlockSize; @@ -22,13 +13,16 @@ export const Input = forwardRef((props, ref) => { return ( & { size?: LabelSize; }; @@ -20,9 +14,10 @@ export const Label = (props: LabelProps) => {