Skip to content
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
164 changes: 164 additions & 0 deletions docs/data/components/selection-and-input/date-picker/web.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,166 @@ const Demo = () => {
export default Demo;`}
/>

## Range picker

`DateRangePicker` 컴포넌트를 사용하여 두 개의 날짜 범위를 선택할 수 있습니다.

<Demo code={`import { DateRangePicker, FlexBox } from '@wanteddev/wds';

const Demo = () => {
return (
<FlexBox flexDirection="column" gap="12px" alignItems="center" sx={{ width: '100%' }}>
<DateRangePicker width="70%" />
</FlexBox>
)
}

export default Demo;`}
/>

### Calendars

Range picker에서는 `calendars` prop 을 사용하여 달력의 개수를 조정할 수 있습니다.

- Responsive props 를 지원하여 Breakpoint 별로 달력의 개수를 조정할 수 있습니다.

<Demo code={`import { DateRangePicker, FlexBox } from '@wanteddev/wds';

const Demo = () => {
return (
<FlexBox flexDirection="column" gap="12px" alignItems="center" sx={{ width: '100%' }}>
<DateRangePicker width="70%" calendars={1} format="YYYY.MM.DD" />
<DateRangePicker width="70%" calendars={2} format="YYYY.MM.DD" />
<DateRangePicker width="70%" calendars={3} format="YYYY.MM.DD" />
</FlexBox>
)
}

export default Demo;`}
/>

### View

Range picker에서는 `view` prop 을 사용하여 선택 UI를 조정할 수 있습니다.

- `'year' | 'month' | 'day'` 를 지원합니다.

<Demo code={`import { DateRangePicker, FlexBox } from '@wanteddev/wds';

const Demo = () => {
return (
<FlexBox flexDirection="column" gap="12px" alignItems="center" sx={{ width: '100%' }}>
<DateRangePicker width="70%" view="day" format="YYYY.MM.DD" />
<DateRangePicker width="70%" view="month" format="YYYY.MM" />
<DateRangePicker width="70%" view="year" format="YYYY" />
</FlexBox>
)
}

export default Demo;`}
/>

### Validation

일반 Picker와 같이 `min`, `max` prop 을 사용하여 최소값과 최대값을 설정할 수 있습니다.

<Demo code={`import { DateRangePicker, FlexBox, FormErrorMessage, FormField, FormLabel, FormControl } from '@wanteddev/wds';
import { Controller, useForm } from 'react-hook-form';

const minDate = new Date('2000-01-01');
const maxDate = new Date('2025-12-31');

const Demo = () => {
const form = useForm({
defaultValues: {
date: [null, null],
},
mode: 'onChange'
});

return (
<FlexBox as="form">
<Controller
control={form.control}
name="date"
rules={{
validate: (value) => {
const [startDate, endDate] = value;
if (isNaN(new Date(startDate).getTime()) || isNaN(new Date(endDate).getTime())) {
return '유효한 날짜를 입력해주세요.';
}

if (new Date(startDate).getTime() < minDate.getTime() || new Date(endDate).getTime() < minDate.getTime()) {
return '최소 날짜는 2000년 1월 1일 이상이어야 합니다.';
}

if (new Date(startDate).getTime() > maxDate.getTime() || new Date(endDate).getTime() > maxDate.getTime()) {
return '최대 날짜는 2025년 12월 31일 이하이어야 합니다.';
}

return true;
},
}}
render={({ field, formState }) => (
<FormField>
<FormLabel>날짜</FormLabel>

<FormControl>
<DateRangePicker
min={minDate}
max={maxDate}
format="YYYY.MM.DD"
width="300px"
inputRef={field.ref}
value={field.value}
onChange={field.onChange}
invalid={!!formState.errors.date}
/>
</FormControl>

<FormErrorMessage>{formState.errors.date?.message}</FormErrorMessage>
</FormField>
)}
/>
</FlexBox>
)
}

export default Demo;`}
/>

### Action area

Range picker에서도 `PickerActionArea` 를 사용하여 하단 영역의 버튼 추가할 수 있습니다.

`now` 를 제외한 3가지 variant를 사용할 수 있습니다.
- accept
- cancel
- reset

`Checkbox` 컴포넌트와 함께 사용할 때에는 `disableLastDateClickClose` prop 을 사용하여 더욱 자연스러운 동작을 구현할 수 있습니다.

<Demo code={`import { DateRangePicker, FlexBox, PickerActionArea, PickerActionAreaButton } from '@wanteddev/wds';

const Demo = () => {
return (
<FlexBox flexDirection="column" gap="12px" alignItems="center" sx={{ width: '100%' }}>
<DateRangePicker
width="70%"
actionArea={(
<PickerActionArea>
<PickerActionAreaButton variant="cancel">취소</PickerActionAreaButton>
<PickerActionAreaButton variant="accept">확인</PickerActionAreaButton>
</PickerActionArea>
)}
/>
</FlexBox>
)
}

export default Demo;`}
/>

## Accessibility

[WAI-ARIA Datepicker dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) 패턴 대부분을 지원합니다.
Expand All @@ -373,6 +533,10 @@ export default Demo;`}

<PropsTable component="DatePicker" />

### DateRangePicker

<PropsTable component="DateRangePicker" />

### PickerActionArea

<PropsTable component="PickerActionArea" />
Expand Down
1 change: 1 addition & 0 deletions packages/wds-mcp/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const getComponentUrl = async (
) => {
const componentSlug = kebabCase(componentName);
const componentPathMap: Record<string, string> = {
'date-range-picker': 'date-picker',
list: 'list-cell',
stepper: 'progress-tracker',
'card-list': 'card',
Expand Down
2 changes: 2 additions & 0 deletions packages/wds/src/components/date-calendar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,8 @@ const DayCalendar = memo(
ref={ref}
role="rowgroup"
{...props}
columnGap="0px"
rowGap="2px"
sx={[dateYearMonthWrapperStyle, props.sx]}
>
{dayRangeRow.map((days, idx) => (
Expand Down
2 changes: 0 additions & 2 deletions packages/wds/src/components/date-calendar/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ export const dateCalendarWrapperStyle = css`
export const dateYearMonthWrapperStyle = css`
padding: 2px 12px;
outline: none;
row-gap: 2px;
column-gap: 0px;
`;

export const dayItemButtonStyle = (theme: Theme) => css`
Expand Down
170 changes: 170 additions & 0 deletions packages/wds/src/components/date-picker/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,3 +730,173 @@ export const getNumericFormatRange = (
};
}
};

/**
* Computes the new section value string for ArrowUp/ArrowDown.
*/
export const getIncrementedSectionValue = (
section: DateFormatSection,
direction: 'up' | 'down',
value: DateType,
timezone: string | undefined,
): string | undefined => {
if (section.type === 'text') {
const optionIndex = section.options.indexOf(section.value);

if (direction === 'up') {
return section.options[
optionIndex === -1 ? 0 : (optionIndex + 1) % section.options.length
];
}

return section.options[
optionIndex === -1
? section.options.length - 1
: optionIndex === 0
? section.options.length - 1
: optionIndex - 1
];
}

const { minValue, maxValue } = getNumericFormatRange(
section.format,
value,
timezone,
);

const parsed = parseInt(section.value);

if (isNaN(parsed)) {
if (section.format === 'YY' || section.format === 'YYYY') {
return dayjsTimezone(dayjs(), timezone).format(section.format);
}
return minValue.toString().padStart(section.format.length, '0');
}

if (direction === 'up') {
return (maxValue <= parsed ? minValue : parsed + 1)
.toString()
.padStart(section.format.length, '0');
}

return (minValue >= parsed ? maxValue : parsed - 1)
.toString()
.padStart(section.format.length, '0');
};

/**
* Computes the section value string for Home/End.
*/
export const getBoundSectionValue = (
section: DateFormatSection,
bound: 'home' | 'end',
value: DateType,
timezone: string | undefined,
): string | undefined => {
if (section.type === 'text') {
return bound === 'home'
? section.options[0]
: section.options[section.options.length - 1];
}

const { minValue, maxValue } = getNumericFormatRange(
section.format,
value,
timezone,
);

const target = bound === 'home' ? minValue : maxValue;
return target.toString().padStart(section.format.length, '0');
};

/**
* Processes a character key input into a section.
* Returns the new input string, whether input is finished, and the updated ref value.
* Returns `undefined` if the key is not valid for the section.
*/
export const processCharacterInput = (
key: string,
section: DateFormatSection,
currentSectionRef: string,
currentInput: string,
locale: string | undefined,
value: DateType,
timezone: string | undefined,
):
| {
newInput: string;
isFinished: boolean;
newSectionRef: string;
}
| undefined => {
const lowerKey = key.toLowerCase();

if (section.type === 'text') {
const foundOption = section.options.filter((v) => {
if (/^a$/i.test(section.format)) {
const meridiem = getMeridiem(locale);
const [am, pm] = meridiem.map((m) =>
section.format === 'a' ? m.lower : m.upper,
);
return new RegExp(
`^${lowerKey === 'a' ? am : lowerKey === 'p' ? pm : '$^'}`,
).test(v);
}
return new RegExp('^' + String.raw`${currentSectionRef}${lowerKey}`).test(
v.toLowerCase(),
);
});

if (foundOption.length > 0) {
return {
newInput:
currentInput.slice(0, section.startIndex) +
foundOption[0] +
currentInput.slice(section.endIndex),
isFinished: foundOption.length === 1,
newSectionRef: currentSectionRef + lowerKey,
};
}

const resetRef = lowerKey;
const fallbackOption = section.options.filter((v) =>
new RegExp('^' + String.raw`${resetRef}`).test(v.toLowerCase()),
);

if (fallbackOption.length === 0) return undefined;

return {
newInput:
currentInput.slice(0, section.startIndex) +
fallbackOption[0] +
currentInput.slice(section.endIndex),
isFinished: fallbackOption.length === 1,
newSectionRef: resetRef,
};
}

// numeric
const numericValue = parseInt(lowerKey);
if (isNaN(numericValue)) return undefined;

const { isComplete } = getNumericFormatRange(section.format, value, timezone);

const newSectionRef = (currentSectionRef + lowerKey).slice(
(section.format.length === 1 ? 2 : section.format.length) * -1,
);

const newInput =
currentInput.slice(0, section.startIndex) +
currentInput
.slice(section.startIndex)
.replace(
section.value,
newSectionRef.replace(/^0+/, '').padStart(section.format.length, '0'),
);

return {
newInput,
isFinished: isComplete(newSectionRef),
newSectionRef,
};
};
Loading
Loading