From 2096f21449eef4b31d213570a5a6a10d54b29ab9 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 18 Mar 2026 13:06:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(wds):=20date=20range=20calendar,=20dat?= =?UTF-8?q?e=20range=20picker=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DateRangeCalendar: 멀티 캘린더 지원, 통합 containerRef 기반 cross-panel 키보드 네비게이션, day/month/year view range 선택, hover preview, 반응형 calendars prop (xs/sm/md/lg/xl) - DateRangePicker: Popper 기반 입력 래퍼, 섹션별 키보드 조작 (useDateRangeField), start↔end 섹션 간 ArrowLeft/Right 이동, paste 지원 - PickerActionArea 통합: mode 'single' | 'range' 지원, now variant + range 시 dev 경고 - 공통 로직 추출: getIncrementedSectionValue, getBoundSectionValue, processCharacterInput - useMedia hook을 hooks/internal/use-media.ts로 분리 (modal에서 추출, 재사용) - splitResponsiveProps를 utils/internal/responsive-props.ts에 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../selection-and-input/date-picker/web.mdx | 164 ++ .../wds/src/components/date-picker/helpers.ts | 170 ++ .../wds/src/components/date-picker/hooks.ts | 610 ++----- .../wds/src/components/date-picker/index.tsx | 5 +- .../date-range-calendar/constants.ts | 3 + .../date-range-calendar/contexts.ts | 29 + .../components/date-range-calendar/helpers.ts | 127 ++ .../components/date-range-calendar/hooks.ts | 101 ++ .../date-range-calendar/index.test.tsx | 267 ++++ .../components/date-range-calendar/index.tsx | 1408 +++++++++++++++++ .../components/date-range-calendar/style.ts | 199 +++ .../components/date-range-calendar/types.ts | 55 + .../components/date-range-picker/helpers.ts | 32 + .../src/components/date-range-picker/hooks.ts | 641 ++++++++ .../date-range-picker/index.test.tsx | 364 +++++ .../components/date-range-picker/index.tsx | 318 ++++ .../src/components/date-range-picker/style.ts | 11 + .../src/components/date-range-picker/types.ts | 78 + packages/wds/src/components/index.ts | 2 + packages/wds/src/components/modal/hooks.ts | 52 +- .../components/picker-action-area/contexts.ts | 12 +- .../components/picker-action-area/index.tsx | 68 +- .../wds/src/components/text-field/style.ts | 10 +- .../wds/src/components/time-picker/index.tsx | 5 +- packages/wds/src/hooks/internal/use-media.ts | 48 + .../src/utils/internal/responsive-props.ts | 33 + 26 files changed, 4207 insertions(+), 605 deletions(-) create mode 100644 packages/wds/src/components/date-range-calendar/constants.ts create mode 100644 packages/wds/src/components/date-range-calendar/contexts.ts create mode 100644 packages/wds/src/components/date-range-calendar/helpers.ts create mode 100644 packages/wds/src/components/date-range-calendar/hooks.ts create mode 100644 packages/wds/src/components/date-range-calendar/index.test.tsx create mode 100644 packages/wds/src/components/date-range-calendar/index.tsx create mode 100644 packages/wds/src/components/date-range-calendar/style.ts create mode 100644 packages/wds/src/components/date-range-calendar/types.ts create mode 100644 packages/wds/src/components/date-range-picker/helpers.ts create mode 100644 packages/wds/src/components/date-range-picker/hooks.ts create mode 100644 packages/wds/src/components/date-range-picker/index.test.tsx create mode 100644 packages/wds/src/components/date-range-picker/index.tsx create mode 100644 packages/wds/src/components/date-range-picker/style.ts create mode 100644 packages/wds/src/components/date-range-picker/types.ts create mode 100644 packages/wds/src/hooks/internal/use-media.ts diff --git a/docs/data/components/selection-and-input/date-picker/web.mdx b/docs/data/components/selection-and-input/date-picker/web.mdx index 9e8ddb83c..691d4957e 100644 --- a/docs/data/components/selection-and-input/date-picker/web.mdx +++ b/docs/data/components/selection-and-input/date-picker/web.mdx @@ -360,6 +360,166 @@ const Demo = () => { export default Demo;`} /> +## Range picker + +`DateRangePicker` 컴포넌트를 사용하여 두 개의 날짜 범위를 선택할 수 있습니다. + + { + return ( + + + + ) +} + +export default Demo;`} +/> + +### Calendars + +Range picker에서는 `calendars` prop 을 사용하여 달력의 개수를 조정할 수 있습니다. + +- Responsive props 를 지원하여 Breakpoint 별로 달력의 개수를 조정할 수 있습니다. + + { + return ( + + + + + + ) +} + +export default Demo;`} +/> + +### View + +Range picker에서는 `view` prop 을 사용하여 선택 UI를 조정할 수 있습니다. + +- `'year' | 'month' | 'day'` 를 지원합니다. + + { + return ( + + + + + + ) +} + +export default Demo;`} +/> + +### Validation + +일반 Picker와 같이 `min`, `max` prop 을 사용하여 최소값과 최대값을 설정할 수 있습니다. + + { + const form = useForm({ + defaultValues: { + date: [null, null], + }, + mode: 'onChange' + }); + + return ( + + { + 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 }) => ( + + 날짜 + + + + + + {formState.errors.date?.message} + + )} + /> + + ) +} + +export default Demo;`} +/> + +### Action area + +Range picker에서도 `PickerActionArea` 를 사용하여 하단 영역의 버튼 추가할 수 있습니다. + +`now` 를 제외한 3가지 variant를 사용할 수 있습니다. +- accept +- cancel +- reset + +`Checkbox` 컴포넌트와 함께 사용할 때에는 `disableLastDateClickClose` prop 을 사용하여 더욱 자연스러운 동작을 구현할 수 있습니다. + + { + return ( + + + 취소 + 확인 + + )} + /> + + ) +} + +export default Demo;`} +/> + ## Accessibility [WAI-ARIA Datepicker dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) 패턴 대부분을 지원합니다. @@ -373,6 +533,10 @@ export default Demo;`} +### DateRangePicker + + + ### PickerActionArea diff --git a/packages/wds/src/components/date-picker/helpers.ts b/packages/wds/src/components/date-picker/helpers.ts index 379165b48..e744868ba 100644 --- a/packages/wds/src/components/date-picker/helpers.ts +++ b/packages/wds/src/components/date-picker/helpers.ts @@ -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, + }; +}; diff --git a/packages/wds/src/components/date-picker/hooks.ts b/packages/wds/src/components/date-picker/hooks.ts index f6997400d..65c9e672d 100644 --- a/packages/wds/src/components/date-picker/hooks.ts +++ b/packages/wds/src/components/date-picker/hooks.ts @@ -1,21 +1,20 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import dayjs from 'dayjs'; import { flushSync } from 'react-dom'; import { dateTypeToDateObject, - dayjsTimezone, - getMeridiem, isDateTypeEmpty, isValidDate, } from '../date-calendar/helpers'; import { + getBoundSectionValue, getClosetSection, getDateformatSections, - getNumericFormatRange, + getIncrementedSectionValue, getRegexFormat, parseFromFormat, + processCharacterInput, toFormat, } from './helpers'; @@ -490,239 +489,57 @@ export const useDateField = ({ return; case 'ArrowUp': + case 'ArrowDown': { e.preventDefault(); if (readOnly || disabled) { return; } - if (focusedSection.type === 'text') { - const optionIndex = focusedSection.options.indexOf( - focusedSection.value, - ); - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - focusedSection.options[ - optionIndex === -1 - ? 0 - : (optionIndex + 1) % focusedSection.options.length - ] + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } else { - const { minValue, maxValue } = getNumericFormatRange( - focusedSection.format, - value, - timezone, - ); - - let newParsedValue: string; - - if (isNaN(parseInt(focusedSection.value))) { - if ( - focusedSection.format === 'YY' || - focusedSection.format === 'YYYY' - ) { - newParsedValue = dayjsTimezone(dayjs(), timezone).format( - focusedSection.format, - ); - } else { - newParsedValue = minValue - .toString() - .padStart(focusedSection.format.length, '0'); - } - } else if (maxValue <= parseInt(focusedSection.value)) { - newParsedValue = minValue - .toString() - .padStart(focusedSection.format.length, '0'); - } else { - newParsedValue = (parseInt(focusedSection.value) + 1) - .toString() - .padStart(focusedSection.format.length, '0'); - } - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - newParsedValue + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); + const direction = e.key === 'ArrowUp' ? 'up' : 'down'; + const newSectionVal = getIncrementedSectionValue( + focusedSection, + direction, + value, + timezone, + ); + if (newSectionVal == null) return; - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); + const newInputValue = + inputValue.slice(0, focusedSection.startIndex) + + newSectionVal + + inputValue.slice(focusedSection.endIndex); - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } + const newSectionValue = getDateformatSections( + newInputValue, + format, + locale, + ); - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } - return; - case 'ArrowDown': - e.preventDefault(); + const parsedDate = parseFromFormat( + newInputValue, + format, + value, + locale, + timezone, + ); - if (readOnly || disabled) { - return; + setInputValue(newInputValue); + setSections(newSectionValue); + setFocusedSection(newSectionValue[focusedSection.index]); + if (parsedDate) { + setValue(parsedDate); + isTriggeredChange.current = true; } - if (focusedSection.type === 'text') { - const optionIndex = focusedSection.options.indexOf( - focusedSection.value, - ); - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - focusedSection.options[ - optionIndex === -1 - ? focusedSection.options.length - 1 - : optionIndex === 0 - ? focusedSection.options.length - 1 - : optionIndex - 1 - ] + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } else { - const { minValue, maxValue } = getNumericFormatRange( - focusedSection.format, - value, - timezone, - ); - - let newParsedValue: string; - - if (isNaN(parseInt(focusedSection.value))) { - if ( - focusedSection.format === 'YY' || - focusedSection.format === 'YYYY' - ) { - newParsedValue = dayjsTimezone(dayjs(), timezone).format( - focusedSection.format, - ); - } else { - newParsedValue = minValue - .toString() - .padStart(focusedSection.format.length, '0'); - } - } else if (minValue >= parseInt(focusedSection.value)) { - newParsedValue = maxValue - .toString() - .padStart(focusedSection.format.length, '0'); - } else { - newParsedValue = (parseInt(focusedSection.value) - 1) - .toString() - .padStart(focusedSection.format.length, '0'); - } - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - newParsedValue + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, + requestAnimationFrame(() => { + inputRef.current?.setSelectionRange( + newSectionValue[focusedSection.index]!.startIndex, + newSectionValue[focusedSection.index]!.endIndex, ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } + }); return; + } case 'ArrowRight': e.preventDefault(); if (focusedSection.index === sections.length - 1) { @@ -762,250 +579,33 @@ export const useDateField = ({ }); return; case 'Home': + case 'End': { e.preventDefault(); if (readOnly || disabled) { return; } - if (focusedSection.type === 'text') { - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - focusedSection.options[0] + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } else { - const { minValue } = getNumericFormatRange( - focusedSection.format, - value, - timezone, - ); - - const newParsedValue = minValue - .toString() - .padStart(focusedSection.format.length, '0'); - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - newParsedValue + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } - return; - case 'End': - e.preventDefault(); - - if (readOnly || disabled) { - return; - } - - if (focusedSection.type === 'text') { - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - focusedSection.options[focusedSection.options.length - 1] + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } else { - const { maxValue } = getNumericFormatRange( - focusedSection.format, - value, - timezone, - ); - - const newParsedValue = maxValue - .toString() - .padStart(focusedSection.format.length, '0'); - - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - newParsedValue + - inputValue.slice(focusedSection.endIndex); - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); - - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); - } - return; - } - - const lowerKey = e.key.toLowerCase(); - - if (e.ctrlKey || e.metaKey || e.altKey || readOnly || disabled) { - return; - } - - e.preventDefault(); - - if (focusedSection.type === 'text') { - const foundOption = focusedSection.options.filter((v) => { - if (/^a$/i.test(focusedSection.format)) { - const meridiem = getMeridiem(locale); - const [am, pm] = meridiem.map((m) => - focusedSection.format === 'a' ? m.lower : m.upper, - ); - - return new RegExp( - `^${lowerKey === 'a' ? am : lowerKey === 'p' ? pm : '$^'}`, - ).test(v); - } - - return new RegExp( - '^' + String.raw`${sectionValueRef.current}${lowerKey}`, - ).test(v.toLowerCase()); - }); - - let newInputValue: string; - let isFinished = false; - - if (foundOption.length > 0) { - newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - foundOption[0] + - inputValue.slice(focusedSection.endIndex); - - sectionValueRef.current += lowerKey; - isFinished = foundOption.length === 1; - } else { - sectionValueRef.current = lowerKey; - - const fallbackOption = focusedSection.options.filter((v) => - new RegExp('^' + String.raw`${sectionValueRef.current}`).test( - v.toLowerCase(), - ), + const bound = e.key === 'Home' ? 'home' : 'end'; + const boundVal = getBoundSectionValue( + focusedSection, + bound, + value, + timezone, ); + if (boundVal == null) return; - if (fallbackOption.length === 0) { - return; - } - - newInputValue = + const newInputValue = inputValue.slice(0, focusedSection.startIndex) + - fallbackOption[0] + + boundVal + inputValue.slice(focusedSection.endIndex); - isFinished = fallbackOption.length === 1; - } - - const newSectionValue = getDateformatSections( - newInputValue, - format, - locale, - ); + const newSectionValue = getDateformatSections( + newInputValue, + format, + locale, + ); - if (isFinished) { - setInputValue(newInputValue); - setSections(newSectionValue); - handleNextSection(newInputValue, newSectionValue); - } else { const parsedDate = parseFromFormat( newInputValue, format, @@ -1028,72 +628,64 @@ export const useDateField = ({ newSectionValue[focusedSection.index]!.endIndex, ); }); - } - } else { - // numeric - const numericValue = parseInt(lowerKey); - - if (isNaN(numericValue)) { return; } + } - const { isComplete } = getNumericFormatRange( - focusedSection.format, - value, - timezone, - ); + if (e.ctrlKey || e.metaKey || e.altKey || readOnly || disabled) { + return; + } - sectionValueRef.current = (sectionValueRef.current + lowerKey).slice( - (focusedSection.format.length === 1 - ? 2 - : focusedSection.format.length) * -1, - ); + e.preventDefault(); - const newInputValue = - inputValue.slice(0, focusedSection.startIndex) + - inputValue - .slice(focusedSection.startIndex) - .replace( - focusedSection.value, - sectionValueRef.current - .replace(/^0+/, '') - .padStart(focusedSection.format.length, '0'), - ); + const charResult = processCharacterInput( + e.key, + focusedSection, + sectionValueRef.current, + inputValue, + locale, + value, + timezone, + ); + + if (!charResult) return; + + const { newInput: newInputValue, isFinished, newSectionRef } = charResult; + sectionValueRef.current = newSectionRef; + + const newSectionValue = getDateformatSections( + newInputValue, + format, + locale, + ); - const newSectionValue = getDateformatSections( + if (isFinished) { + setInputValue(newInputValue); + setSections(newSectionValue); + handleNextSection(newInputValue, newSectionValue); + } else { + const parsedDate = parseFromFormat( newInputValue, format, + value, locale, + timezone, ); - if (isComplete(sectionValueRef.current)) { - setInputValue(newInputValue); - setSections(newSectionValue); - handleNextSection(newInputValue, newSectionValue); - } else { - const parsedDate = parseFromFormat( - newInputValue, - format, - value, - locale, - timezone, - ); - - setInputValue(newInputValue); - setSections(newSectionValue); - setFocusedSection(newSectionValue[focusedSection.index]); - if (parsedDate) { - setValue(parsedDate); - isTriggeredChange.current = true; - } - - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange( - newSectionValue[focusedSection.index]!.startIndex, - newSectionValue[focusedSection.index]!.endIndex, - ); - }); + setInputValue(newInputValue); + setSections(newSectionValue); + setFocusedSection(newSectionValue[focusedSection.index]); + if (parsedDate) { + setValue(parsedDate); + isTriggeredChange.current = true; } + + requestAnimationFrame(() => { + inputRef.current?.setSelectionRange( + newSectionValue[focusedSection.index]!.startIndex, + newSectionValue[focusedSection.index]!.endIndex, + ); + }); } }, [ diff --git a/packages/wds/src/components/date-picker/index.tsx b/packages/wds/src/components/date-picker/index.tsx index a743af9a4..951f3a4be 100644 --- a/packages/wds/src/components/date-picker/index.tsx +++ b/packages/wds/src/components/date-picker/index.tsx @@ -22,6 +22,7 @@ import type { SlotProps } from '@radix-ui/react-slot'; import type { DefaultComponentPropsInternal } from '@wanteddev/wds-engine'; import type { DatePickerFieldProps, DatePickerProps } from './types'; import type { DateType } from '../date-calendar/types'; +import type { DateRangeType } from '../date-range-calendar/types'; extendDayjs(); @@ -138,8 +139,8 @@ const DatePicker = forwardRef< ); const handleChangeCompleteActionArea = useCallback( - (v: DateType) => { - handleChangeComplete(v); + (v: DateType | DateRangeType) => { + handleChangeComplete(v as DateType); setOpen(false); }, [handleChangeComplete, setOpen], diff --git a/packages/wds/src/components/date-range-calendar/constants.ts b/packages/wds/src/components/date-range-calendar/constants.ts new file mode 100644 index 000000000..9d5b9b8f9 --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/constants.ts @@ -0,0 +1,3 @@ +import type { DateRangeType } from './types'; + +export const DEFAULT_RANGE_VALUE: DateRangeType = [undefined, undefined]; diff --git a/packages/wds/src/components/date-range-calendar/contexts.ts b/packages/wds/src/components/date-range-calendar/contexts.ts new file mode 100644 index 000000000..7a3927ddc --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/contexts.ts @@ -0,0 +1,29 @@ +import { createContext } from '@radix-ui/react-context'; + +import type { DateType, ViewType } from '../date-calendar/types'; +import type { DateRangeType } from './types'; +import type { Dayjs } from 'dayjs'; +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +export type DateRangeCalendarContextType = { + rangeValue: DateRangeType; + hoveredDate: Date | null; + setHoveredDate: Dispatch>; + activePosition: 'start' | 'end'; + handleDateSelect: (date: Date) => void; + defaultSelectedDate: Date; + setDefaultSelectedDate: Dispatch>; + now: Dayjs; + min: DateType; + max: DateType; + locale?: string; + timezone?: string; + containerRef: RefObject; + view: ViewType; + calendars: number; + disabled?: boolean; + readOnly?: boolean; +}; + +export const [DateRangeCalendarContextProvider, useDateRangeCalendarContext] = + createContext('DateRangeCalendar'); diff --git a/packages/wds/src/components/date-range-calendar/helpers.ts b/packages/wds/src/components/date-range-calendar/helpers.ts new file mode 100644 index 000000000..24ed9eb5a --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/helpers.ts @@ -0,0 +1,127 @@ +import dayjs from 'dayjs'; + +import { + dateTypeToDateObject, + dayjsTimezone, + isValidDate, +} from '../date-calendar/helpers'; + +import type { Dayjs } from 'dayjs'; +import type { DateType, ViewType } from '../date-calendar/types'; +import type { DateRangeType } from './types'; + +export const isSameDateForView = ( + date1: DateType, + date2: DateType, + view: ViewType, + timezone?: string, +): boolean => { + if (!isValidDate(date1) || !isValidDate(date2)) return false; + + const d1 = dayjsTimezone(dayjs(date1), timezone); + const d2 = dayjsTimezone(dayjs(date2), timezone); + + return d1.isSame(d2, view); +}; + +export const isDateInRangeForView = ( + date: DateType, + start: DateType, + end: DateType, + view: ViewType, + timezone?: string, +): boolean => { + if (!isValidDate(date) || !isValidDate(start) || !isValidDate(end)) + return false; + + const d = dayjsTimezone(dayjs(date), timezone); + const s = dayjsTimezone(dayjs(start), timezone); + const e = dayjsTimezone(dayjs(end), timezone); + + return d.isBetween(s, e, view, '[]'); +}; + +export const getDisplayRange = ( + rangeValue: DateRangeType, + hoveredDate: Date | null, + activePosition: 'start' | 'end', + timezone?: string, +): DateRangeType => { + const [start, end] = rangeValue; + + if (isValidDate(start) && isValidDate(end)) { + return rangeValue; + } + + if (isValidDate(start) && activePosition === 'end' && hoveredDate) { + const startDate = dateTypeToDateObject(start, timezone); + if (dayjs(hoveredDate).isBefore(dayjs(startDate), 'day')) { + return [hoveredDate, startDate]; + } + return [startDate, hoveredDate]; + } + + return [undefined, undefined]; +}; + +/** + * Focuses a date element in the range calendar container. + * Uses string-based data attributes (ISO format for day/month, number string for year). + */ +export const focusRangeDate = ( + type: ViewType, + value: string, + containerRef: { current: HTMLDivElement | null }, +) => { + switch (type) { + case 'day': + return containerRef.current + ?.querySelector( + `[data-date="${value}"]:not([aria-disabled='true'])`, + ) + ?.focus(); + case 'month': + return containerRef.current + ?.querySelector(`[data-month="${value}"]`) + ?.focus(); + case 'year': + return containerRef.current + ?.querySelector(`[data-year="${value}"]`) + ?.focus(); + } +}; + +export const scrollIntoViewRangeDate = ( + type: ViewType, + value: string, + containerRef: { current: HTMLDivElement | null }, +) => { + switch (type) { + case 'year': + return containerRef.current + ?.querySelector(`[data-year="${value}"]`) + ?.scrollIntoView({ block: 'center' }); + case 'month': + return containerRef.current + ?.querySelector(`[data-month="${value}"]`) + ?.scrollIntoView({ block: 'center' }); + } +}; + +export const isDateInVisiblePanels = ( + date: Dayjs, + baseDate: Date, + calendars: number, + timezone?: string, +): boolean => { + const base = dayjsTimezone(dayjs(baseDate), timezone); + + for (let i = 0; i < calendars; i++) { + const panelMonth = base.add(i, 'month'); + if (date.isSame(panelMonth, 'month')) { + return true; + } + } + + return false; +}; diff --git a/packages/wds/src/components/date-range-calendar/hooks.ts b/packages/wds/src/components/date-range-calendar/hooks.ts new file mode 100644 index 000000000..ce011bbed --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/hooks.ts @@ -0,0 +1,101 @@ +import { useCallback, useState } from 'react'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import dayjs from 'dayjs'; + +import { + dateTypeToDateObject, + dayjsTimezone, + isValidDate, +} from '../date-calendar/helpers'; + +import { DEFAULT_RANGE_VALUE } from './constants'; + +import type { DateRangeType } from './types'; + +type UseRangeSelectionParams = { + value?: DateRangeType; + defaultValue?: DateRangeType; + onChange?: (value: DateRangeType) => void; + onChangeComplete?: (value: DateRangeType) => void; + timezone?: string; + disabled?: boolean; + readOnly?: boolean; +}; + +export const useRangeSelection = ({ + value, + defaultValue, + onChange, + onChangeComplete, + timezone, + disabled, + readOnly, +}: UseRangeSelectionParams) => { + const [rangeValue, setRangeValue] = useControllableState({ + prop: value, + defaultProp: defaultValue ?? DEFAULT_RANGE_VALUE, + onChange, + }); + + const [activePosition, setActivePosition] = useState<'start' | 'end'>( + 'start', + ); + const [hoveredDate, setHoveredDate] = useState(null); + + const handleDateSelect = useCallback( + (date: Date) => { + if (disabled || readOnly) return; + + if (activePosition === 'start') { + const newRange: DateRangeType = [date, undefined]; + setRangeValue(newRange); + setActivePosition('end'); + } else { + const start = rangeValue[0]; + if (isValidDate(start)) { + const startDate = dateTypeToDateObject(start, timezone); + let newRange: DateRangeType; + + if ( + dayjsTimezone(dayjs(date), timezone).isBefore( + dayjsTimezone(dayjs(startDate), timezone), + 'day', + ) + ) { + newRange = [date, startDate]; + } else { + newRange = [startDate, date]; + } + + setRangeValue(newRange); + setHoveredDate(null); + onChangeComplete?.(newRange); + setActivePosition('start'); + } else { + const newRange: DateRangeType = [date, undefined]; + setRangeValue(newRange); + setActivePosition('end'); + } + } + }, + [ + activePosition, + disabled, + onChangeComplete, + rangeValue, + readOnly, + setRangeValue, + timezone, + ], + ); + + return { + rangeValue, + setRangeValue, + activePosition, + setActivePosition, + hoveredDate, + setHoveredDate, + handleDateSelect, + }; +}; diff --git a/packages/wds/src/components/date-range-calendar/index.test.tsx b/packages/wds/src/components/date-range-calendar/index.test.tsx new file mode 100644 index 000000000..74bb02c8c --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/index.test.tsx @@ -0,0 +1,267 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { axe } from 'vitest-axe'; + +import { DateRangeCalendar } from '.'; + +Object.defineProperty(window.Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true, +}); + +describe('when given date range calendar component', () => { + const defaultProps = { + defaultValue: [ + new Date('2025-01-15T00:00:00'), + new Date('2025-01-20T00:00:00'), + ] as [Date, Date], + onChange: vi.fn(), + locale: 'en-US', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render day view by default', () => { + render(); + + expect(screen.getByRole('grid')).toBeInTheDocument(); + expect(screen.getByText('January 2025')).toBeInTheDocument(); + }); + + it('should handle range selection with two clicks', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + + render( + , + ); + + // First click sets start + const day10 = screen.getByText('10'); + fireEvent.click(day10); + + expect(onChange).toHaveBeenCalledWith([expect.any(Date), undefined]); + + // Second click sets end + const day20 = screen.getByText('20'); + fireEvent.click(day20); + + expect(onChangeComplete).toHaveBeenCalledWith([ + expect.any(Date), + expect.any(Date), + ]); + }); + + it('should swap start and end when end is before start', () => { + const onChangeComplete = vi.fn(); + + render( + , + ); + + // Click later date first + fireEvent.click(screen.getByText('20')); + // Click earlier date second + fireEvent.click(screen.getByText('10')); + + const [start, end] = onChangeComplete.mock.calls[0]![0] as [Date, Date]; + expect(start.getDate()).toBeLessThanOrEqual(end.getDate()); + }); + + it('should navigate months with arrow buttons', () => { + render(); + + const nextButton = screen.getByLabelText('Next month'); + const prevButton = screen.getByLabelText('Previous month'); + + fireEvent.click(nextButton); + expect(screen.getByText('February 2025')).toBeInTheDocument(); + + fireEvent.click(prevButton); + expect(screen.getByText('January 2025')).toBeInTheDocument(); + }); + + it('should render multiple calendars when calendars prop is set', () => { + render(); + + expect(screen.getByText('January 2025')).toBeInTheDocument(); + expect(screen.getByText('February 2025')).toBeInTheDocument(); + }); + + it('should not render other month dates', () => { + render( + , + ); + + // February 2025 starts on Saturday, so there are leading empty cells + // Day "1" should appear only once (Feb 1), not as Jan 31's neighbor + const dayOnes = screen.getAllByText('1'); + expect(dayOnes).toHaveLength(1); + }); + + it('should handle keyboard navigation in day view', async () => { + render(); + + const day15 = screen.getByText('15'); + day15.focus(); + + // ArrowRight moves to next day (uses requestAnimationFrame) + fireEvent.keyDown(day15, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(screen.getByText('16')).toHaveFocus(); + }); + }); + + it('should render month view', () => { + render(); + + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getByText('Jan')).toBeInTheDocument(); + expect(screen.getByText('Dec')).toBeInTheDocument(); + }); + + it('should handle month range selection', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByText('Mar')); + expect(onChange).toHaveBeenCalledWith([expect.any(Date), undefined]); + + fireEvent.click(screen.getByText('Jun')); + expect(onChangeComplete).toHaveBeenCalledWith([ + expect.any(Date), + expect.any(Date), + ]); + }); + + it('should render year view', () => { + render(); + + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: '2025 Year' }), + ).toBeInTheDocument(); + }); + + it('should handle year range selection', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole('radio', { name: '2024 Year' })); + expect(onChange).toHaveBeenCalledWith([expect.any(Date), undefined]); + + fireEvent.click(screen.getByRole('radio', { name: '2026 Year' })); + expect(onChangeComplete).toHaveBeenCalledWith([ + expect.any(Date), + expect.any(Date), + ]); + }); + + it('should handle keyboard navigation in year view', async () => { + render(); + + const year2025 = screen.getByRole('radio', { name: '2025 Year' }); + year2025.focus(); + + fireEvent.keyDown(year2025, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(screen.getByRole('radio', { name: '2026 Year' })).toHaveFocus(); + }); + }); + + it('should disable navigation buttons at min/max boundaries', () => { + render( + , + ); + + expect(screen.getByLabelText('Previous month')).toBeDisabled(); + expect(screen.getByLabelText('Next month')).toBeDisabled(); + }); + + it('should handle controlled value', () => { + const value: [Date, Date] = [ + new Date('2025-03-10T00:00:00'), + new Date('2025-03-20T00:00:00'), + ]; + + render(); + + expect(screen.getByText('March 2025')).toBeInTheDocument(); + }); + + it('should show header label without view toggle button', () => { + render(); + + const headerLabel = screen.getByText('January 2025'); + + // Header should be a non-interactive label, not a button + expect(headerLabel.closest('button')).toBeNull(); + }); + + it('should pass accessibility tests for day view', async () => { + render(); + + expect(await axe(screen.getByRole('rowgroup'))).toHaveNoViolations(); + }); + + it('should pass accessibility tests for month view', async () => { + render(); + + expect(await axe(screen.getByRole('radiogroup'))).toHaveNoViolations(); + }); + + it('should pass accessibility tests for year view', async () => { + render(); + + expect(await axe(screen.getByRole('radiogroup'))).toHaveNoViolations(); + }); +}); diff --git a/packages/wds/src/components/date-range-calendar/index.tsx b/packages/wds/src/components/date-range-calendar/index.tsx new file mode 100644 index 000000000..15e5786f4 --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/index.tsx @@ -0,0 +1,1408 @@ +import { + forwardRef, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import dayjs from 'dayjs'; +import { + IconChevronLeftSmall, + IconChevronRightSmall, +} from '@wanteddev/wds-icon'; +import { + Box, + type DefaultComponentPropsInternal, + useTheme, +} from '@wanteddev/wds-engine'; +import { useComposedRefs } from '@radix-ui/react-compose-refs'; + +import { FlexBox } from '../flex-box'; +import { IconButton } from '../icon-button'; +import { Grid } from '../grid'; +import { GridItem } from '../grid-item'; +import { WithInteraction } from '../with-interaction'; +import { ScrollArea } from '../scroll-area'; +import { Typography } from '../typography'; +import { extendDayjs } from '../../utils/internal/date'; +import { useMedia } from '../../hooks/internal/use-media'; +import { getPreviousValue } from '../../utils/internal/responsive-props'; +import { useDefaultSelectedDate } from '../date-calendar/hooks'; +import { + ACCESSIBLE_MAX_DATE, + ACCESSIBLE_MIN_DATE, +} from '../date-calendar/constants'; +import { + dateTypeToDateObject, + dayjsTimezone, + findClosestEnableDate, + getWeekdays, + isDisabledDate, + isValidDate, +} from '../date-calendar/helpers'; + +import { + focusRangeDate, + getDisplayRange, + isDateInRangeForView, + isDateInVisiblePanels, + isSameDateForView, + scrollIntoViewRangeDate, +} from './helpers'; +import { + rangeCalendarContainerStyle, + rangeDateItemStyle, + rangeDayCellStyle, + rangeDayItemStyle, + rangeGridWrapperStyle, + rangeMonthYearCellStyle, + rangePanelHeaderLabelStyle, + rangePanelHeaderNavigationStyle, + rangePanelHeaderStyle, + rangePanelStyle, + rangePanelWrapperStyle, + rangeStickyHeaderStyle, + rangeWeekdayCellStyle, +} from './style'; +import { useRangeSelection } from './hooks'; +import { + DateRangeCalendarContextProvider, + useDateRangeCalendarContext, +} from './contexts'; + +import type { BreakPoint } from '@wanteddev/wds-engine'; +import type { Dayjs } from 'dayjs'; +import type { KeyboardEvent } from 'react'; +import type { + DateRangeCalendarProps, + DateRangeType, + RangeDateItemProps, + RangeDayItemProps, +} from './types'; + +extendDayjs(); + +const DateRangeCalendar = forwardRef< + HTMLDivElement, + DefaultComponentPropsInternal +>( + ( + { + value: originValue, + defaultValue, + onChange, + onChangeComplete, + calendars: givenCalendars = 1, + max = ACCESSIBLE_MAX_DATE, + min = ACCESSIBLE_MIN_DATE, + view = 'day', + locale = 'ko-KR', + yearsOrder = 'asc', + timezone, + disabled, + readOnly, + xs, + sm, + md, + lg, + xl, + ...props + }, + ref, + ) => { + const theme = useTheme(); + + const breakpoints = useMemo( + () => Object.keys(theme.breakpoint) as Array, + // eslint-disable-next-line react-hooks/exhaustive-deps + Object.values(theme), + ); + + const calendars = + useMedia( + breakpoints.map((v) => `(min-width: ${theme.breakpoint[v]})`), + breakpoints.map((v) => + getPreviousValue( + { xs, sm, md, lg, xl }, + 'calendars', + givenCalendars, + v, + ), + ), + givenCalendars, + ) ?? givenCalendars; + + const { + rangeValue, + activePosition, + hoveredDate, + setHoveredDate, + handleDateSelect: baseHandleDateSelect, + } = useRangeSelection({ + value: originValue, + defaultValue, + onChange, + onChangeComplete, + timezone, + disabled, + readOnly, + }); + + // Use initial value only — don't auto-navigate when dates are selected + const initialStart = useRef(originValue?.[0] ?? defaultValue?.[0]).current; + + const { defaultSelectedDate, setDefaultSelectedDate, now } = + useDefaultSelectedDate(initialStart, min, max, timezone); + + const containerRef = useRef(null); + const composedRefs = useComposedRefs(ref, containerRef); + + const effectiveCalendars = view === 'day' ? Math.max(1, calendars) : 1; + + return ( + + { + if (!disabled && !readOnly) setHoveredDate(null); + }} + > + {view === 'day' && + Array.from({ length: effectiveCalendars }).map((_, panelIdx) => ( + + ))} + {view === 'month' && } + {view === 'year' && } + + + ); + }, +); + +DateRangeCalendar.displayName = 'DateRangeCalendar'; + +type RangeDayPanelProps = { + panelIndex: number; +}; + +const RangeDayPanel = memo(({ panelIndex }: RangeDayPanelProps) => { + const { + defaultSelectedDate, + setDefaultSelectedDate, + locale, + timezone, + min, + max, + calendars, + } = useDateRangeCalendarContext('RangeDayPanel'); + + const panelMonth = useMemo( + () => + dayjsTimezone(dayjs(defaultSelectedDate), timezone).add( + panelIndex, + 'month', + ), + [defaultSelectedDate, panelIndex, timezone], + ); + + const headerLabel = useMemo( + () => + Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + timeZone: timezone, + }).format(dateTypeToDateObject(panelMonth, timezone)), + [locale, panelMonth, timezone], + ); + + const weekdays = useMemo(() => getWeekdays(locale), [locale]); + + const showPrevArrow = panelIndex === 0; + const showNextArrow = panelIndex === calendars - 1; + + const isOnlyOneCalendar = calendars === 1; + + const prevArrow = useMemo(() => { + return ( + + setDefaultSelectedDate( + dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).subtract( + 1, + 'month', + ), + timezone, + ), + ) + } + > + + + ); + }, [min, defaultSelectedDate, timezone, setDefaultSelectedDate]); + + const nextArrow = useMemo(() => { + return ( + + setDefaultSelectedDate( + dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).add( + 1, + 'month', + ), + timezone, + ), + ) + } + > + + + ); + }, [max, defaultSelectedDate, timezone, calendars, setDefaultSelectedDate]); + + return ( + + + + + {isOnlyOneCalendar ? ( + <> + + + + + + {prevArrow} + {nextArrow} + + + ) : ( + + + {showPrevArrow ? prevArrow : } + + + + + + + + {showNextArrow ? nextArrow : } + + + )} + + + + {weekdays.map((day, i) => ( + + {day.narrow} + + ))} + + + + + + + + + ); +}); + +RangeDayPanel.displayName = 'RangeDayPanel'; + +type RangeDayGridProps = { + panelMonth: Dayjs; + panelIndex: number; +}; + +const RangeDayGrid = memo(({ panelMonth, panelIndex }: RangeDayGridProps) => { + const { + min, + max, + rangeValue, + hoveredDate, + activePosition, + handleDateSelect, + setHoveredDate, + defaultSelectedDate, + setDefaultSelectedDate, + now, + containerRef, + timezone, + calendars, + disabled, + readOnly, + } = useDateRangeCalendarContext('RangeDayGrid'); + + const displayRange = useMemo( + () => getDisplayRange(rangeValue, hoveredDate, activePosition, timezone), + [rangeValue, hoveredDate, activePosition, timezone], + ); + + const dayRange = useMemo(() => { + const firstDayOfMonth = panelMonth.set('date', 1); + + const prevMonthDays = new Array(firstDayOfMonth.weekday()) + .fill(0) + .map((_, i) => { + const nextDay = firstDayOfMonth.day(i); + return { + value: nextDay, + disabled: isDisabledDate({ + min, + max, + value: dateTypeToDateObject(nextDay, timezone), + timezone, + }), + label: nextDay.date(), + isOtherMonth: true, + }; + }); + + const monthDays = new Array(firstDayOfMonth.daysInMonth()) + .fill(0) + .map((_, i) => { + const nextDay = firstDayOfMonth.date(i + 1); + return { + value: nextDay, + disabled: isDisabledDate({ + min, + max, + value: dateTypeToDateObject(nextDay, timezone), + timezone, + }), + label: nextDay.date(), + isOtherMonth: false, + }; + }); + + const allDays = [...prevMonthDays, ...monthDays]; + + const nextMonthDays = new Array( + allDays.length / 7 > 5 + ? 6 - firstDayOfMonth.date(firstDayOfMonth.daysInMonth()).weekday() + : 13 - firstDayOfMonth.date(firstDayOfMonth.daysInMonth()).weekday(), + ) + .fill(0) + .map((_, i) => { + const nextDay = firstDayOfMonth.date( + firstDayOfMonth.daysInMonth() + i + 1, + ); + return { + value: nextDay, + disabled: isDisabledDate({ + min, + max, + value: dateTypeToDateObject(nextDay, timezone), + timezone, + }), + label: nextDay.date(), + isOtherMonth: true, + }; + }); + + return [...allDays, ...nextMonthDays]; + }, [panelMonth, min, max, timezone]); + + const dayRangeRows = useMemo(() => { + return dayRange.reduce( + (acc, cur, idx) => { + const chunkIndex = Math.floor(idx / 7); + if (!acc[chunkIndex]) { + acc[chunkIndex] = []; + } + acc[chunkIndex].push(cur); + return acc; + }, + [] as Array, + ); + }, [dayRange]); + + const [focusedIdx, setFocusedIdx] = useState( + dayRange.findIndex((v) => !v.isOtherMonth), + ); + + useEffect( + () => { + if (panelIndex !== 0) { + setFocusedIdx(dayRange.findIndex((v) => !v.isOtherMonth)); + return; + } + + const [start] = rangeValue; + const selectedDateIdx = isValidDate(start) + ? dayRange.findIndex( + (v) => + !v.isOtherMonth && + !v.disabled && + v.value.format('YYYY MM DD') === + dayjsTimezone(dayjs(start), timezone).format('YYYY MM DD'), + ) + : -1; + + if (selectedDateIdx !== -1) { + setFocusedIdx(selectedDateIdx); + return; + } + + const todayDateIdx = isValidDate(now.toDate()) + ? dayRange.findIndex( + (v) => + !v.isOtherMonth && + !v.disabled && + v.value.format('YYYY MM DD') === + dayjsTimezone(now, timezone).format('YYYY MM DD'), + ) + : -1; + + if (todayDateIdx !== -1) { + setFocusedIdx(todayDateIdx); + return; + } + + setFocusedIdx(dayRange.findIndex((v) => !v.isOtherMonth && !v.disabled)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [panelMonth.format('YYYY-MM')], + ); + + const handleClick = useCallback( + (date: Date) => () => { + const clamped = findClosestEnableDate({ + min, + max, + value: date, + timezone, + }); + handleDateSelect(clamped); + }, + [handleDateSelect, max, min, timezone], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const currentDateStr = e.currentTarget.getAttribute('data-date'); + if (!currentDateStr) return; + + const current = dayjsTimezone(dayjs(currentDateStr), timezone); + let target: Dayjs; + + switch (e.key) { + case 'ArrowUp': + target = current.subtract(7, 'day'); + break; + case 'ArrowDown': + target = current.add(7, 'day'); + break; + case 'ArrowLeft': + target = current.subtract(1, 'day'); + break; + case 'ArrowRight': + target = current.add(1, 'day'); + break; + case 'Home': + target = current.startOf('week'); + break; + case 'End': + target = current.endOf('week'); + break; + case 'PageUp': + target = current.subtract(1, 'month'); + break; + case 'PageDown': + target = current.add(1, 'month'); + break; + default: + return; + } + + e.preventDefault(); + + const clamped = findClosestEnableDate({ + min, + max, + value: dateTypeToDateObject(target, timezone), + timezone, + }); + + const clampedDayjs = dayjsTimezone(dayjs(clamped), timezone); + + if ( + !isDateInVisiblePanels( + clampedDayjs, + defaultSelectedDate, + calendars, + timezone, + ) + ) { + const base = dayjsTimezone(dayjs(defaultSelectedDate), timezone); + const lastPanelMonth = base.add(calendars - 1, 'month'); + + if (clampedDayjs.isBefore(base, 'month')) { + setDefaultSelectedDate( + dateTypeToDateObject(clampedDayjs.startOf('month'), timezone), + ); + } else if (clampedDayjs.isAfter(lastPanelMonth, 'month')) { + setDefaultSelectedDate( + dateTypeToDateObject( + clampedDayjs.subtract(calendars - 1, 'month').startOf('month'), + timezone, + ), + ); + } + } + + setHoveredDate(clamped); + + requestAnimationFrame(() => { + focusRangeDate('day', clampedDayjs.format('YYYY-MM-DD'), containerRef); + }); + }, + [ + calendars, + containerRef, + defaultSelectedDate, + max, + min, + setDefaultSelectedDate, + setHoveredDate, + timezone, + ], + ); + + return ( + + {dayRangeRows.map((days, rowIdx) => ( + + {days.map((day, dayIdx) => { + const dateValue = day.value.format('YYYY-MM-DD'); + const isOtherMonth = day.value.month() !== panelMonth.month(); + + if (isOtherMonth) { + return ( + + ); + } + + const dateObj = dateTypeToDateObject(day.value, timezone); + + const isRangeStart = isSameDateForView( + dateObj, + displayRange[0], + 'day', + timezone, + ); + const isRangeEnd = isSameDateForView( + dateObj, + displayRange[1], + 'day', + timezone, + ); + const isInRange = isDateInRangeForView( + dateObj, + displayRange[0], + displayRange[1], + 'day', + timezone, + ); + const isSelected = isRangeStart || isRangeEnd; + + return ( + + { + if (!disabled && !readOnly) setHoveredDate(dateObj); + }} + > + {day.label} + + + ); + })} + + ))} + + ); +}); + +RangeDayGrid.displayName = 'RangeDayGrid'; + +const RangeMonthPanel = memo(() => { + const { + defaultSelectedDate, + setDefaultSelectedDate, + locale, + timezone, + min, + max, + containerRef, + rangeValue, + hoveredDate, + activePosition, + handleDateSelect, + setHoveredDate, + now, + disabled, + readOnly, + } = useDateRangeCalendarContext('RangeMonthPanel'); + + const headerLabel = useMemo( + () => + Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + timeZone: timezone, + }).format(defaultSelectedDate), + [defaultSelectedDate, locale, timezone], + ); + + const displayRange = useMemo( + () => getDisplayRange(rangeValue, hoveredDate, activePosition, timezone), + [rangeValue, hoveredDate, activePosition, timezone], + ); + + const monthRange = useMemo(() => { + return new Array(12).fill(0).map((_, i) => { + const minDate = dayjsTimezone( + dayjs(min ?? ACCESSIBLE_MIN_DATE), + timezone, + ); + const maxDate = dayjsTimezone( + dayjs(max ?? ACCESSIBLE_MAX_DATE), + timezone, + ); + const currentMonth = dayjsTimezone( + dayjs(defaultSelectedDate), + timezone, + ).set('month', i); + + return { + value: i, + label: Intl.DateTimeFormat(locale, { month: 'short' }).format( + dateTypeToDateObject(dayjs().set('month', i), timezone), + ), + disabled: + (currentMonth.isBefore(minDate, 'month') && + minDate.year() >= + dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()) || + (currentMonth.isAfter(maxDate, 'month') && + maxDate.year() <= + dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()), + }; + }); + }, [min, timezone, max, locale, defaultSelectedDate]); + + const [focusedIdx, setFocusedIdx] = useState( + monthRange.findIndex((v) => !v.disabled), + ); + + useEffect( + () => { + const [start] = rangeValue; + const selectedDateIdx = isValidDate(start) + ? monthRange.findIndex( + (v) => + !v.disabled && + v.value === dayjsTimezone(dayjs(start), timezone).month(), + ) + : -1; + + if (selectedDateIdx !== -1) { + setFocusedIdx(selectedDateIdx); + const mv = `${dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()}-${String(monthRange[selectedDateIdx]!.value + 1).padStart(2, '0')}`; + scrollIntoViewRangeDate('month', mv, containerRef); + focusRangeDate('month', mv, containerRef); + return; + } + + const todayDateIdx = isValidDate(now.toDate()) + ? monthRange.findIndex( + (v) => + !v.disabled && + v.value === dayjsTimezone(now, timezone).month() && + dayjsTimezone(now, timezone).year() === + dayjsTimezone(dayjs(defaultSelectedDate), timezone).year(), + ) + : -1; + + if (todayDateIdx !== -1) { + setFocusedIdx(todayDateIdx); + return; + } + + setFocusedIdx(monthRange.findIndex((v) => !v.disabled)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + monthRange.map((v) => v.value), + ); + + const handleClick = useCallback( + (monthIdx: number) => () => { + const newValue = findClosestEnableDate({ + min, + max, + timezone, + value: dateTypeToDateObject( + dayjs(defaultSelectedDate).set('month', monthIdx), + timezone, + ), + }); + handleDateSelect(newValue); + }, + [defaultSelectedDate, handleDateSelect, max, min, timezone], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const currentMonthStr = e.currentTarget.getAttribute('data-month'); + if (!currentMonthStr) return; + + const currentMonth = Number( + e.currentTarget.getAttribute('data-month-index') ?? '0', + ); + let newMonth: number; + + switch (e.key) { + case 'ArrowUp': + newMonth = Math.max(0, currentMonth - 3); + break; + case 'ArrowDown': + newMonth = Math.min(11, currentMonth + 3); + break; + case 'ArrowLeft': + newMonth = Math.max(0, currentMonth - 1); + break; + case 'ArrowRight': + newMonth = Math.min(11, currentMonth + 1); + break; + case 'Home': + newMonth = 0; + break; + case 'End': + newMonth = 11; + break; + default: + return; + } + + e.preventDefault(); + + setFocusedIdx(monthRange.findIndex((v) => v.value === newMonth)); + + const monthDate = dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( + 'month', + newMonth, + ), + timezone, + ); + setHoveredDate(monthDate); + + const mv = `${dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()}-${String(newMonth + 1).padStart(2, '0')}`; + requestAnimationFrame(() => { + focusRangeDate('month', mv, containerRef); + }); + }, + [containerRef, defaultSelectedDate, monthRange, setHoveredDate, timezone], + ); + + return ( + + + + + + + + + + setDefaultSelectedDate( + dateTypeToDateObject( + dayjsTimezone( + dayjs(defaultSelectedDate), + timezone, + ).subtract(1, 'year'), + timezone, + ), + ) + } + > + + + = + dayjsTimezone(dayjs(max), timezone).year() + } + onClick={() => + setDefaultSelectedDate( + dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).add( + 1, + 'year', + ), + timezone, + ), + ) + } + > + + + + + + + + + {monthRange.map((month, i) => { + const monthDate = dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( + 'month', + month.value, + ), + timezone, + ); + const year = dayjsTimezone( + dayjs(defaultSelectedDate), + timezone, + ).year(); + const monthVal = `${year}-${String(month.value + 1).padStart(2, '0')}`; + + const isRangeStart = isSameDateForView( + monthDate, + displayRange[0], + 'month', + timezone, + ); + const isRangeEnd = isSameDateForView( + monthDate, + displayRange[1], + 'month', + timezone, + ); + const isInRange = isDateInRangeForView( + monthDate, + displayRange[0], + displayRange[1], + 'month', + timezone, + ); + + return ( + + + { + if (!disabled && !readOnly) setHoveredDate(monthDate); + }} + > + {month.label} + + + + ); + })} + + + + + ); +}); + +RangeMonthPanel.displayName = 'RangeMonthPanel'; + +type RangeYearPanelProps = { + yearsOrder?: 'desc' | 'asc'; +}; + +const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { + const { + defaultSelectedDate, + locale, + timezone, + min, + max, + containerRef, + rangeValue, + hoveredDate, + activePosition, + handleDateSelect, + setHoveredDate, + now, + disabled, + readOnly, + } = useDateRangeCalendarContext('RangeYearPanel'); + + const headerLabel = useMemo( + () => + Intl.DateTimeFormat(locale, { + year: 'numeric', + timeZone: timezone, + }).format(defaultSelectedDate), + [defaultSelectedDate, locale, timezone], + ); + + const displayRange = useMemo( + () => getDisplayRange(rangeValue, hoveredDate, activePosition, timezone), + [rangeValue, hoveredDate, activePosition, timezone], + ); + + const yearRange = useMemo(() => { + const startDate = dayjsTimezone( + dayjs(min ?? ACCESSIBLE_MIN_DATE), + timezone, + ); + const endDate = dayjsTimezone(dayjs(max ?? ACCESSIBLE_MAX_DATE), timezone); + const years: Array = []; + + let current = startDate; + while (current.year() <= endDate.year()) { + years.push(current.get('year')); + current = current.add(1, 'year'); + } + + return yearsOrder === 'asc' ? years : years.reverse(); + }, [min, timezone, max, yearsOrder]); + + const [focusedIdx, setFocusedIdx] = useState(yearRange.length > 0 ? 0 : -1); + + useEffect( + () => { + const [start] = rangeValue; + const selectedDateIdx = isValidDate(start) + ? yearRange.findIndex( + (v) => v === dayjsTimezone(dayjs(start), timezone).year(), + ) + : -1; + + if (selectedDateIdx !== -1) { + scrollIntoViewRangeDate( + 'year', + String(yearRange[selectedDateIdx]!), + containerRef, + ); + focusRangeDate( + 'year', + String(yearRange[selectedDateIdx]!), + containerRef, + ); + setFocusedIdx(selectedDateIdx); + return; + } + + const todayDateIdx = isValidDate(now.toDate()) + ? yearRange.findIndex((v) => v === dayjsTimezone(now, timezone).year()) + : -1; + + if (todayDateIdx !== -1) { + scrollIntoViewRangeDate( + 'year', + String(yearRange[todayDateIdx]!), + containerRef, + ); + focusRangeDate('year', String(yearRange[todayDateIdx]!), containerRef); + setFocusedIdx(todayDateIdx); + return; + } + + const fallbackDateIdx = yearRange.length > 0 ? 0 : -1; + if (fallbackDateIdx !== -1) { + focusRangeDate( + 'year', + String(yearRange[fallbackDateIdx]!), + containerRef, + ); + } + setFocusedIdx(fallbackDateIdx); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + yearRange.map((v) => v), + ); + + const handleClick = useCallback( + (year: number) => () => { + const newValue = findClosestEnableDate({ + min, + max, + value: dateTypeToDateObject( + dayjs(defaultSelectedDate).set('year', year), + timezone, + ), + timezone, + }); + handleDateSelect(newValue); + }, + [defaultSelectedDate, handleDateSelect, max, min, timezone], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const currentYearStr = e.currentTarget.getAttribute('data-year'); + if (!currentYearStr) return; + + const currentYear = Number(currentYearStr); + let newYear: number; + + switch (e.key) { + case 'ArrowUp': + newYear = currentYear - 3; + break; + case 'ArrowDown': + newYear = currentYear + 3; + break; + case 'ArrowLeft': + newYear = currentYear - 1; + break; + case 'ArrowRight': + newYear = currentYear + 1; + break; + case 'Home': + newYear = yearRange[0] ?? currentYear; + break; + case 'End': + newYear = yearRange[yearRange.length - 1] ?? currentYear; + break; + default: + return; + } + + e.preventDefault(); + + const clamped = findClosestEnableDate({ + min, + max, + value: dateTypeToDateObject( + dayjs(defaultSelectedDate).year(newYear), + timezone, + ), + timezone, + }); + const clampedYear = dayjsTimezone(dayjs(clamped), timezone).year(); + + setFocusedIdx(yearRange.findIndex((v) => v === clampedYear)); + + const yearDate = dateTypeToDateObject( + dayjs(defaultSelectedDate).set('year', clampedYear), + timezone, + ); + setHoveredDate(yearDate); + + requestAnimationFrame(() => { + focusRangeDate('year', String(clampedYear), containerRef); + }); + }, + [ + containerRef, + defaultSelectedDate, + max, + min, + setHoveredDate, + timezone, + yearRange, + ], + ); + + return ( + + + + + + + + + + + + + + {yearRange.map((year, i) => { + const yearDate = dateTypeToDateObject( + dayjs(defaultSelectedDate).set('year', year), + timezone, + ); + const yearVal = String(year); + + const isRangeStart = isSameDateForView( + yearDate, + displayRange[0], + 'year', + timezone, + ); + const isRangeEnd = isSameDateForView( + yearDate, + displayRange[1], + 'year', + timezone, + ); + const isInRange = isDateInRangeForView( + yearDate, + displayRange[0], + displayRange[1], + 'year', + timezone, + ); + + return ( + + + { + if (!disabled && !readOnly) setHoveredDate(yearDate); + }} + > + {year} + + + + ); + })} + + + + + ); +}); + +RangeYearPanel.displayName = 'RangeYearPanel'; + +type PanelHeaderLabelProps = { + label: string; +}; + +const PanelHeaderLabel = memo(({ label }: PanelHeaderLabelProps) => { + return ( + + {label} + + ); +}); + +PanelHeaderLabel.displayName = 'PanelHeaderLabel'; + +const RangeDayItem = forwardRef< + HTMLButtonElement, + DefaultComponentPropsInternal +>(({ disabled, isCurrent, isOtherMonth, isActive, ...props }, ref) => { + return ( + + + + ); +}); + +RangeDayItem.displayName = 'RangeDayItem'; + +const RangeDateItem = memo( + forwardRef< + HTMLButtonElement, + DefaultComponentPropsInternal + >(({ disabled, isCurrent, isOtherMonth, isActive, ...props }, ref) => { + return ( + + + + ); + }), +); + +RangeDateItem.displayName = 'RangeDateItem'; + +export { DateRangeCalendar }; + +export type { DateRangeCalendarProps, DateRangeType }; diff --git a/packages/wds/src/components/date-range-calendar/style.ts b/packages/wds/src/components/date-range-calendar/style.ts new file mode 100644 index 000000000..f55708c34 --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/style.ts @@ -0,0 +1,199 @@ +import { css } from '@wanteddev/wds-engine'; + +import { addOpacity, typographyStyle } from '../../utils'; + +import type { Theme } from '@wanteddev/wds-engine'; + +export const rangeCalendarContainerStyle = css` + display: flex; + flex-direction: row; +`; + +export const rangePanelStyle = (theme: Theme) => css` + width: 276px; + background-color: ${theme.semantic.background.elevated.normal}; + flex-shrink: 0; +`; + +export const rangePanelHeaderStyle = css` + padding: 20px 12px 10px 12px; +`; + +export const rangePanelHeaderLabelStyle = css` + padding: 0px 12px; +`; + +export const rangePanelHeaderNavigationStyle = css` + padding: 3px 9px; +`; + +export const rangePanelWrapperStyle = css` + height: 334px; + width: 276px; + + [data-radix-scroll-area-viewport] { + scroll-padding-top: 54px; + } +`; + +export const rangeStickyHeaderStyle = (theme: Theme) => css` + top: 0; + z-index: 10; + position: sticky; + background: ${addOpacity( + theme.semantic.background.elevated.normal, + theme.opacity[88], + )}; + backdrop-filter: blur(32px); +`; + +export const rangeWeekdayCellStyle = css` + padding: 11px 0px; + width: 36px; +`; + +export const rangeGridWrapperStyle = css` + padding: 2px 12px; + outline: none; + row-gap: 2px; + column-gap: 0px; +`; + +/** Shared range cell base — holds the range band ::before pseudo-element */ +const rangeCellBaseStyle = (theme: Theme) => css` + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + + &::before { + content: ''; + position: absolute; + top: 2px; + bottom: 2px; + left: 0; + right: 0; + background-color: transparent; + pointer-events: none; + } + + &[data-in-range='true']::before { + background-color: ${addOpacity( + theme.semantic.primary.normal, + theme.opacity[8], + )}; + } + + &[data-range-start='true']::before { + background-color: ${addOpacity( + theme.semantic.primary.normal, + theme.opacity[8], + )}; + left: 50%; + } + + &[data-range-end='true']::before { + background-color: ${addOpacity( + theme.semantic.primary.normal, + theme.opacity[8], + )}; + right: 50%; + } + + &[data-range-start='true'][data-range-end='true']::before { + display: none; + } +`; + +/** Day cell wrapper — holds the range band ::before and the pill button */ +export const rangeDayCellStyle = (theme: Theme) => css` + ${rangeCellBaseStyle(theme)} + width: 36px; +`; + +/** Month/Year cell wrapper for range band */ +export const rangeMonthYearCellStyle = rangeCellBaseStyle; + +/** Shared range item base — color, border, padding, typography, states */ +const rangeItemBaseStyle = (theme: Theme) => css` + color: ${theme.semantic.label.normal}; + border: none; + padding: 7px 0px; + margin: 2px; + background-color: transparent; + position: relative; + z-index: 1; + + ${typographyStyle('label2', 'medium')} + + &:disabled { + cursor: initial; + color: ${theme.semantic.label.assistive}; + } + + &[data-other-month='true'] { + color: ${theme.semantic.label.disable}; + } + + &[aria-current='date'] { + color: ${theme.semantic.primary.normal}; + background-color: ${addOpacity( + theme.semantic.primary.normal, + theme.opacity[8], + )}; + + &:disabled, + &[data-other-month='true'] { + color: ${theme.semantic.label.disable}; + background-color: ${addOpacity( + theme.semantic.primary.normal, + theme.opacity[8], + )}; + } + } + + &:not(:hover):not(:active) > [wds-component='with-interaction'] { + transition: none; + } + + &:focus-visible { + outline: none; + + & > [wds-component='with-interaction'] { + transition: none; + opacity: 0.06; + } + } +`; + +/** Day button inside the range cell */ +export const rangeDayItemStyle = (theme: Theme) => css` + ${rangeItemBaseStyle(theme)} + border-radius: 10000px; + + &[aria-selected='true'] { + color: ${theme.semantic.static.white}; + background-color: ${theme.semantic.primary.normal}; + &:disabled, + &[data-other-month='true'] { + color: ${addOpacity(theme.semantic.static.white, theme.opacity[43])}; + background-color: ${theme.semantic.primary.normal}; + } + } +`; + +/** Month/Year button inside the range cell */ +export const rangeDateItemStyle = (theme: Theme) => css` + ${rangeItemBaseStyle(theme)} + border-radius: 8px; + + &[aria-checked='true'] { + color: ${theme.semantic.static.white}; + background-color: ${theme.semantic.primary.normal}; + &:disabled { + color: ${addOpacity(theme.semantic.static.white, theme.opacity[43])}; + background-color: ${theme.semantic.primary.normal}; + } + } +`; diff --git a/packages/wds/src/components/date-range-calendar/types.ts b/packages/wds/src/components/date-range-calendar/types.ts new file mode 100644 index 000000000..addfcb16e --- /dev/null +++ b/packages/wds/src/components/date-range-calendar/types.ts @@ -0,0 +1,55 @@ +import type { + Merge, + ResponsiveProps, + WithSxProps, +} from '@wanteddev/wds-engine'; +import type { DateType, ViewType } from '../date-calendar/types'; + +export type DateRangeType = [DateType, DateType]; + +type DateRangeCalendarDefaultProps = { + /** The value of the date range. */ + value?: DateRangeType; + /** The default value of the date range. */ + defaultValue?: DateRangeType; + /** Callback function when the value changes. */ + onChange?: (value: DateRangeType) => void; + /** Callback function when the date range selection is completed. */ + onChangeComplete?: (value: DateRangeType) => void; + /** The number of calendars to display. Only effective in day view. */ + calendars?: number; + /** The view of the calendar. */ + view?: ViewType; + /** The maximum date. */ + max?: DateType; + /** The minimum date. */ + min?: DateType; + /** The locale of the date. */ + locale?: string; + /** The timezone of the date. */ + timezone?: string; + /** Whether the calendar is disabled. */ + disabled?: boolean; + /** Whether the calendar is read only. */ + readOnly?: boolean; + /** The order of the years. */ + yearsOrder?: 'desc' | 'asc'; +}; + +export type DateRangeCalendarProps = Merge< + WithSxProps, + ResponsiveProps> +>; + +export type RangeItemProps = WithSxProps<{ + isActive?: boolean; + isCurrent?: boolean; + isOtherMonth?: boolean; + isRangeStart?: boolean; + isRangeEnd?: boolean; + isInRange?: boolean; +}>; + +export type RangeDayItemProps = RangeItemProps; + +export type RangeDateItemProps = RangeItemProps; diff --git a/packages/wds/src/components/date-range-picker/helpers.ts b/packages/wds/src/components/date-range-picker/helpers.ts new file mode 100644 index 000000000..3d0a34f3c --- /dev/null +++ b/packages/wds/src/components/date-range-picker/helpers.ts @@ -0,0 +1,32 @@ +import type { DateRangeType } from '../date-range-calendar/types'; + +const toDateOnly = (date: Date): number => { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).getTime(); +}; + +export const isInvalidDateRange = (value: DateRangeType): boolean => { + const [start, end] = value; + + if (Boolean(start) && isNaN(new Date(start!).getTime())) { + return true; + } + + if (Boolean(end) && isNaN(new Date(end!).getTime())) { + return true; + } + + if (Boolean(start) && Boolean(end)) { + const startDay = toDateOnly(new Date(start!)); + const endDay = toDateOnly(new Date(end!)); + + if (startDay > endDay) { + return true; + } + } + + return false; +}; diff --git a/packages/wds/src/components/date-range-picker/hooks.ts b/packages/wds/src/components/date-range-picker/hooks.ts new file mode 100644 index 000000000..42c72f43b --- /dev/null +++ b/packages/wds/src/components/date-range-picker/hooks.ts @@ -0,0 +1,641 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; + +import { isDateTypeEmpty, isValidDate } from '../date-calendar/helpers'; +import { + getBoundSectionValue, + getClosetSection, + getDateformatSections, + getIncrementedSectionValue, + getRegexFormat, + parseFromFormat, + processCharacterInput, + toFormat, +} from '../date-picker/helpers'; + +import type { DateType } from '../date-calendar/types'; +import type { DateRangeType } from '../date-range-calendar/types'; +import type { DateFormatSection } from '../date-picker/helpers'; +import type { + ClipboardEvent, + Dispatch, + FocusEvent, + KeyboardEvent, + MouseEvent, + SetStateAction, +} from 'react'; + +const RANGE_SEPARATOR = ' - '; + +type RangePosition = 'start' | 'end'; + +type RangeFocusedSection = DateFormatSection & { + position: RangePosition; +}; + +type UseDateRangeFieldParams = { + value: DateRangeType; + format: string; + locale?: string; + timezone?: string; + setValue: Dispatch>; + readOnly?: boolean; + disabled?: boolean; +}; + +const buildCombinedValue = (startValue: string, endValue: string): string => { + return `${startValue}${RANGE_SEPARATOR}${endValue}`; +}; + +export const useDateRangeField = ({ + value, + format = 'YYYY.MM.DD', + locale, + timezone, + setValue, + readOnly, + disabled, +}: UseDateRangeFieldParams) => { + const inputRef = useRef(null); + + const [startInputValue, setStartInputValue] = useState( + isValidDate(value[0]) ? toFormat(value[0], format, locale, timezone) : '', + ); + const [endInputValue, setEndInputValue] = useState( + isValidDate(value[1]) ? toFormat(value[1], format, locale, timezone) : '', + ); + + const [startSections, setStartSections] = useState( + getDateformatSections(startInputValue || format, format, locale), + ); + const [endSections, setEndSections] = useState( + getDateformatSections(endInputValue || format, format, locale), + ); + + const [focusedSection, setFocusedSection] = useState(); + + const sectionValueRef = useRef(''); + const isTriggeredChange = useRef(false); + const isFirstRender = useRef(true); + const focusTimestamp = useRef(0); + + useEffect(() => { + sectionValueRef.current = ''; + }, [focusedSection?.index, focusedSection?.position]); + + // Derive combined values + const activeStartInput = startInputValue || format; + const activeEndInput = endInputValue || format; + const inputValue = + !startInputValue && !endInputValue + ? '' + : buildCombinedValue(activeStartInput, activeEndInput); + + const endOffset = activeStartInput.length + RANGE_SEPARATOR.length; + + // Sync from external value changes + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + if (isTriggeredChange.current) { + isTriggeredChange.current = false; + return; + } + + const newStart = isValidDate(value[0]) + ? toFormat(value[0], format, locale, timezone) + : ''; + const newEnd = isValidDate(value[1]) + ? toFormat(value[1], format, locale, timezone) + : ''; + + setStartInputValue(newStart); + setEndInputValue(newEnd); + setStartSections(getDateformatSections(newStart || format, format, locale)); + setEndSections(getDateformatSections(newEnd || format, format, locale)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value[0], value[1], format, locale, timezone]); + + // --- Helpers --- + + const getCurrentValue = useCallback( + (position: RangePosition): DateType => { + return position === 'start' ? value[0] : value[1]; + }, + [value], + ); + + const getCurrentInputValue = useCallback( + (position: RangePosition): string => { + return position === 'start' ? activeStartInput : activeEndInput; + }, + [activeStartInput, activeEndInput], + ); + + const updateInputAndSections = useCallback( + (position: RangePosition, newInputValue: string) => { + const newSections = getDateformatSections(newInputValue, format, locale); + + if (position === 'start') { + setStartInputValue(newInputValue); + setStartSections(newSections); + } else { + setEndInputValue(newInputValue); + setEndSections(newSections); + } + + return newSections; + }, + [format, locale], + ); + + const tryParse = useCallback( + (position: RangePosition, inputStr: string) => { + const parsed = parseFromFormat( + inputStr, + format, + getCurrentValue(position), + locale, + timezone, + ); + + if (parsed && !readOnly && !disabled) { + const newRange: DateRangeType = + position === 'start' ? [parsed, value[1]] : [value[0], parsed]; + setValue(newRange); + isTriggeredChange.current = true; + } + }, + [ + disabled, + format, + getCurrentValue, + locale, + readOnly, + setValue, + timezone, + value, + ], + ); + + const makeFocused = useCallback( + ( + section: DateFormatSection, + position: RangePosition, + ): RangeFocusedSection => { + return { ...section, position }; + }, + [], + ); + + const setSelectionForSection = useCallback( + (section: DateFormatSection, position: RangePosition) => { + const offset = position === 'start' ? 0 : endOffset; + requestAnimationFrame(() => { + inputRef.current?.setSelectionRange( + section.startIndex + offset, + section.endIndex + offset, + ); + }); + }, + [endOffset], + ); + + const handleNextSection = useCallback( + ( + position: RangePosition, + newInputStr: string, + newSections: Array, + currentLocalIndex: number, + ) => { + const nextLocalSection = newSections[currentLocalIndex + 1]; + + tryParse(position, newInputStr); + + if (nextLocalSection) { + sectionValueRef.current = ''; + setFocusedSection(makeFocused(nextLocalSection, position)); + setSelectionForSection(nextLocalSection, position); + } else if (position === 'start') { + // Move to first section of end + const firstEndSection = endSections[0]; + if (firstEndSection) { + sectionValueRef.current = ''; + setFocusedSection(makeFocused(firstEndSection, 'end')); + setSelectionForSection(firstEndSection, 'end'); + } + } else { + // Stay at last section + const lastSection = newSections[currentLocalIndex]; + if (lastSection) { + const resolved = + newSections.find((s) => s.format === lastSection.format) ?? + lastSection; + setFocusedSection(makeFocused(resolved, position)); + setSelectionForSection(resolved, position); + } + } + }, + [endSections, makeFocused, setSelectionForSection, tryParse], + ); + + // --- Event handlers --- + + const handleValueChange = useCallback( + (v: DateRangeType) => { + isTriggeredChange.current = true; + + const newStart = isValidDate(v[0]) + ? toFormat(v[0], format, locale, timezone) + : ''; + const newEnd = isValidDate(v[1]) + ? toFormat(v[1], format, locale, timezone) + : ''; + + setStartInputValue(newStart); + setEndInputValue(newEnd); + setStartSections( + getDateformatSections(newStart || format, format, locale), + ); + setEndSections(getDateformatSections(newEnd || format, format, locale)); + setValue(v); + }, + [format, locale, setValue, timezone], + ); + + const handleFocus = useCallback( + (e: FocusEvent) => { + if ( + e.currentTarget.tagName !== 'TEXTAREA' && + e.currentTarget.tagName !== 'INPUT' + ) { + return; + } + + if (!startInputValue && !endInputValue) { + focusTimestamp.current = e.timeStamp; + } + + const newStart = startInputValue || format; + const newEnd = endInputValue || format; + const newStartSections = getDateformatSections(newStart, format, locale); + const newEndSections = getDateformatSections(newEnd, format, locale); + + flushSync(() => { + setStartInputValue(newStart); + setEndInputValue(newEnd); + setStartSections(newStartSections); + setEndSections(newEndSections); + }); + + const closetSection = getClosetSection(0, newStartSections); + if (closetSection) { + setFocusedSection(makeFocused(closetSection, 'start')); + e.currentTarget.setSelectionRange( + closetSection.startIndex, + closetSection.endIndex, + ); + } + }, + [endInputValue, format, locale, makeFocused, startInputValue], + ); + + const handleClick = useCallback( + (e: MouseEvent) => { + if (!('setSelectionRange' in e.currentTarget)) return; + + let cursorPosition = e.currentTarget.selectionStart ?? 0; + + if ( + (!startInputValue && !endInputValue) || + e.timeStamp - focusTimestamp.current < 300 + ) { + cursorPosition = 0; + } + + // Determine position based on cursor + const position: RangePosition = + cursorPosition < endOffset ? 'start' : 'end'; + const localSections = position === 'start' ? startSections : endSections; + const offset = position === 'start' ? 0 : endOffset; + + const adjustedCursor = cursorPosition - offset; + const closetSection = getClosetSection( + Math.max(0, adjustedCursor), + localSections, + ); + + if (closetSection) { + e.preventDefault(); + setFocusedSection(makeFocused(closetSection, position)); + e.currentTarget.setSelectionRange( + closetSection.startIndex + offset, + closetSection.endIndex + offset, + ); + } + }, + [ + endInputValue, + endOffset, + endSections, + makeFocused, + startInputValue, + startSections, + ], + ); + + const handleBlur = useCallback(() => { + setFocusedSection(undefined); + sectionValueRef.current = ''; + isTriggeredChange.current = false; + + const startEmpty = startInputValue === format || isDateTypeEmpty(value[0]); + const endEmpty = endInputValue === format || isDateTypeEmpty(value[1]); + + if (startEmpty && endEmpty) { + setStartInputValue(''); + setEndInputValue(''); + } + }, [endInputValue, format, startInputValue, value]); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + e.preventDefault(); + if (!focusedSection || readOnly || disabled) return; + + const pastedText = e.clipboardData.getData('text'); + const { position } = focusedSection; + const localSections = position === 'start' ? startSections : endSections; + const currentInput = getCurrentInputValue(position); + const localIndex = focusedSection.index; + const section = localSections[localIndex]; + if (!section) return; + + // Try full range paste (e.g., "2024.09.21 - 2024.09.26") + if ( + e.currentTarget.selectionStart === 0 && + e.currentTarget.selectionEnd === inputValue.length + ) { + const parts = pastedText.split(RANGE_SEPARATOR); + if (parts.length === 2) { + const parsedStart = parseFromFormat( + parts[0]!, + format, + value[0], + locale, + timezone, + ); + const parsedEnd = parseFromFormat( + parts[1]!, + format, + value[1], + locale, + timezone, + ); + if (parsedStart && parsedEnd) { + handleValueChange([parsedStart, parsedEnd]); + return; + } + } + } + + if (section.type === 'text') { + const regex = getRegexFormat(section.format, locale); + const match = pastedText.match(regex); + if (match) { + const newInput = + currentInput.slice(0, section.startIndex) + + match[0] + + currentInput.slice(section.endIndex); + const newSections = updateInputAndSections(position, newInput); + handleNextSection(position, newInput, newSections, localIndex); + } + } else { + const numericValue = parseInt(pastedText); + if (!isNaN(numericValue)) { + const padded = numericValue + .toString() + .slice( + (section.format.length === 1 ? 2 : section.format.length) * -1, + ) + .replace(/^0+/, '') + .padStart(section.format.length, '0'); + const newInput = + currentInput.slice(0, section.startIndex) + + padded + + currentInput.slice(section.endIndex); + const newSections = updateInputAndSections(position, newInput); + handleNextSection(position, newInput, newSections, localIndex); + } + } + }, + [ + disabled, + endSections, + focusedSection, + format, + getCurrentInputValue, + handleNextSection, + handleValueChange, + inputValue.length, + locale, + readOnly, + startSections, + timezone, + updateInputAndSections, + value, + ], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if ( + (e.currentTarget.tagName !== 'TEXTAREA' && + e.currentTarget.tagName !== 'INPUT') || + !focusedSection + ) { + return; + } + + const { position } = focusedSection; + const localSections = position === 'start' ? startSections : endSections; + const currentInput = getCurrentInputValue(position); + const localIndex = focusedSection.index; + const section = localSections[localIndex]; + if (!section) return; + + const updateSection = (newSectionValue: string) => { + const newInput = + currentInput.slice(0, section.startIndex) + + newSectionValue + + currentInput.slice(section.endIndex); + const newSections = updateInputAndSections(position, newInput); + tryParse(position, newInput); + const updatedSection = newSections[localIndex]; + if (updatedSection) { + setFocusedSection(makeFocused(updatedSection, position)); + setSelectionForSection(updatedSection, position); + } + }; + + switch (e.key) { + case 'Tab': + return; + + case 'Backspace': { + e.preventDefault(); + if (readOnly || disabled) return; + sectionValueRef.current = ''; + + const clearedInput = + currentInput.slice(0, section.startIndex) + + section.format + + currentInput.slice(section.endIndex); + const clearedSections = updateInputAndSections( + position, + clearedInput, + ); + tryParse(position, clearedInput); + const clearedSection = clearedSections[localIndex]; + if (clearedSection) { + setFocusedSection(makeFocused(clearedSection, position)); + setSelectionForSection(clearedSection, position); + } + return; + } + + case 'ArrowRight': { + e.preventDefault(); + const nextLocal = localSections[localIndex + 1]; + if (nextLocal) { + setFocusedSection(makeFocused(nextLocal, position)); + setSelectionForSection(nextLocal, position); + } else if (position === 'start') { + const firstEnd = endSections[0]; + if (firstEnd) { + setFocusedSection(makeFocused(firstEnd, 'end')); + setSelectionForSection(firstEnd, 'end'); + } + } + return; + } + + case 'ArrowLeft': { + e.preventDefault(); + const prevLocal = localSections[localIndex - 1]; + if (prevLocal) { + setFocusedSection(makeFocused(prevLocal, position)); + setSelectionForSection(prevLocal, position); + } else if (position === 'end') { + const lastStart = startSections[startSections.length - 1]; + if (lastStart) { + setFocusedSection(makeFocused(lastStart, 'start')); + setSelectionForSection(lastStart, 'start'); + } + } + return; + } + + case 'ArrowUp': + case 'ArrowDown': { + e.preventDefault(); + if (readOnly || disabled) return; + + const direction = e.key === 'ArrowUp' ? 'up' : 'down'; + const newVal = getIncrementedSectionValue( + section, + direction, + getCurrentValue(position), + timezone, + ); + if (newVal != null) updateSection(newVal); + return; + } + + case 'Home': + case 'End': { + e.preventDefault(); + if (readOnly || disabled) return; + + const bound = e.key === 'Home' ? 'home' : 'end'; + const boundVal = getBoundSectionValue( + section, + bound, + getCurrentValue(position), + timezone, + ); + if (boundVal != null) updateSection(boundVal); + return; + } + } + + // Character input + if (e.ctrlKey || e.metaKey || e.altKey || readOnly || disabled) return; + e.preventDefault(); + + const charResult = processCharacterInput( + e.key, + section, + sectionValueRef.current, + currentInput, + locale, + getCurrentValue(position), + timezone, + ); + + if (!charResult) return; + + const { newInput, isFinished, newSectionRef } = charResult; + sectionValueRef.current = newSectionRef; + + const newSections = updateInputAndSections(position, newInput); + + if (isFinished) { + handleNextSection(position, newInput, newSections, localIndex); + } else { + tryParse(position, newInput); + const updated = newSections[localIndex]; + if (updated) { + setFocusedSection(makeFocused(updated, position)); + setSelectionForSection(updated, position); + } + } + }, + [ + disabled, + endSections, + focusedSection, + getCurrentInputValue, + getCurrentValue, + handleNextSection, + makeFocused, + locale, + readOnly, + setSelectionForSection, + startSections, + timezone, + tryParse, + updateInputAndSections, + ], + ); + + const handleInputValueChange = useCallback(() => { + // no-op: input value is derived from state + }, []); + + return { + inputRef, + inputValue, + focusedSection, + handlePaste, + handleFocus, + handleClick, + handleBlur, + handleKeyDown, + handleValueChange, + handleInputValueChange, + }; +}; diff --git a/packages/wds/src/components/date-range-picker/index.test.tsx b/packages/wds/src/components/date-range-picker/index.test.tsx new file mode 100644 index 000000000..903dfc1ed --- /dev/null +++ b/packages/wds/src/components/date-range-picker/index.test.tsx @@ -0,0 +1,364 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DateRangePicker } from '.'; + +Object.defineProperty(window.Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true, +}); + +describe('when given date range picker component', () => { + const defaultProps = { + defaultValue: [ + new Date('2025-01-15T00:00:00'), + new Date('2025-02-20T00:00:00'), + ] as [Date, Date], + onChange: vi.fn(), + locale: 'en-US', + format: 'YYYY.MM.DD', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + // --- Rendering --- + + it('should render with default range value', () => { + render(); + + const input = screen.getByTestId('range-picker'); + + expect(input).toHaveValue('2025.01.15 - 2025.02.20'); + }); + + it('should render empty when no value', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + expect(input).toHaveValue(''); + }); + + it('should show format placeholder on focus when empty', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + expect(input).toHaveValue('YYYY.MM.DD - YYYY.MM.DD'); + }); + + // --- Keyboard section navigation --- + + it('should navigate between start date sections with arrow keys', async () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + await waitFor(() => { + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(4); + }); + + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(input.selectionStart).toBe(5); + expect(input.selectionEnd).toBe(7); + }); + + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(input.selectionStart).toBe(8); + expect(input.selectionEnd).toBe(10); + }); + }); + + it('should navigate from start to end date sections', async () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + // "2025.01.15 - 2025.02.20" → end YYYY starts at 13 + await waitFor(() => { + expect(input.selectionStart).toBe(13); + expect(input.selectionEnd).toBe(17); + }); + }); + + it('should navigate from end to start date sections', async () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + // Navigate to end date YYYY + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + // ArrowLeft from end YYYY → start DD + fireEvent.keyDown(input, { key: 'ArrowLeft' }); + + await waitFor(() => { + expect(input.selectionStart).toBe(8); + expect(input.selectionEnd).toBe(10); + }); + }); + + // --- Value modification (start) --- + + it('should modify start date year with ArrowUp/Down', () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + + expect(input).toHaveValue('2024.01.15 - 2025.02.20'); + + fireEvent.keyDown(input, { key: 'ArrowUp' }); + + expect(input).toHaveValue('2025.01.15 - 2025.02.20'); + }); + + it('should modify start date year with Home/End', () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'End' }); + + expect(input).toHaveValue('2100.01.15 - 2025.02.20'); + + fireEvent.keyDown(input, { key: 'Home' }); + + expect(input).toHaveValue('1900.01.15 - 2025.02.20'); + }); + + // --- Value modification (end) --- + + it('should modify end date year with ArrowUp/Down', async () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + // Navigate to end YYYY + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(input.selectionStart).toBe(13); + }); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + + expect(input).toHaveValue('2025.01.15 - 2024.02.20'); + + fireEvent.keyDown(input, { key: 'ArrowUp' }); + + expect(input).toHaveValue('2025.01.15 - 2025.02.20'); + }); + + // --- Backspace --- + + it('should clear start section with Backspace', () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'Backspace' }); + + expect(input).toHaveValue('YYYY.01.15 - 2025.02.20'); + }); + + it('should clear end section with Backspace', async () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + // Navigate to end YYYY + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(input.selectionStart).toBe(13); + }); + + fireEvent.keyDown(input, { key: 'Backspace' }); + + expect(input).toHaveValue('2025.01.15 - YYYY.02.20'); + }); + + // --- Paste --- + + it('should handle paste for full range', () => { + render(); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + input.setSelectionRange(0, input.value.length); + + fireEvent.paste(input, { + clipboardData: { + getData: () => '2024.06.01 - 2024.12.31', + }, + }); + + expect(input).toHaveValue('2024.06.01 - 2024.12.31'); + }); + + // --- Calendar popup --- + + it('should open calendar when calendar icon is clicked', () => { + render(); + + fireEvent.click(screen.getByLabelText('Toggle date range picker')); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // --- Invalid state --- + + it('should be invalid when start > end in uncontrolled mode', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should not be invalid when start equals end', () => { + const sameDate = new Date('2025-01-15T00:00:00'); + + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + expect(input).not.toHaveAttribute('aria-invalid', 'true'); + }); + + // --- Disabled / ReadOnly --- + + it('should not modify value when disabled', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + expect(input).toBeDisabled(); + }); + + it('should not modify value when readOnly', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'ArrowUp' }); + + expect(input).toHaveValue('2025.01.15 - 2025.02.20'); + }); + + // --- Custom format --- + + it('should render with custom format', () => { + render( + , + ); + + const input = screen.getByTestId('range-picker'); + + expect(input).toHaveValue('15/01/2025 - 20/02/2025'); + }); + + // --- View prop --- + + it('should pass view prop to calendar', () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText('Toggle date range picker')); + + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + }); +}); diff --git a/packages/wds/src/components/date-range-picker/index.tsx b/packages/wds/src/components/date-range-picker/index.tsx new file mode 100644 index 000000000..718885970 --- /dev/null +++ b/packages/wds/src/components/date-range-picker/index.tsx @@ -0,0 +1,318 @@ +import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'; +import { IconCalendar } from '@wanteddev/wds-icon'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; +import { useComposedRefs } from '@radix-ui/react-compose-refs'; +import { composeEventHandlers } from '@radix-ui/primitive'; + +import { TextField, TextFieldContent } from '../text-field'; +import { IconButton } from '../icon-button'; +import { DateRangeCalendar } from '../date-range-calendar'; +import { Popper, PopperAnchor, PopperContent } from '../popper'; +import { DismissableLayer } from '../dismissable-layer'; +import { FocusScope } from '../focus-scope'; +import { FlexBox } from '../flex-box'; +import { extendDayjs } from '../../utils/internal/date'; +import { splitResponsiveProps } from '../../utils/internal/responsive-props'; +import { DEFAULT_RANGE_VALUE } from '../date-range-calendar/constants'; +import { PickerActionAreaProvider } from '../picker-action-area/contexts'; + +import { isInvalidDateRange } from './helpers'; +import { dateRangePopperStyle } from './style'; +import { useDateRangeField } from './hooks'; + +import type { SlotProps } from '@radix-ui/react-slot'; +import type { DefaultComponentPropsInternal } from '@wanteddev/wds-engine'; +import type { DateRangePickerFieldProps, DateRangePickerProps } from './types'; +import type { DateType } from '../date-calendar/types'; +import type { DateRangeType } from '../date-range-calendar/types'; + +extendDayjs(); + +const calendarKeys = ['calendars'] as Array<'calendars'>; + +const DateRangePicker = forwardRef< + HTMLDivElement, + DefaultComponentPropsInternal +>( + ( + { + disabled, + readOnly, + value: originValue, + defaultValue, + onChange, + defaultOpen, + open: originOpen, + onOpenChange, + view, + contentProps, + format = 'YYYY.MM.DD', + placeholder, + min, + max, + locale = 'ko-KR', + timezone, + onChangeComplete, + inputRef: originInputRef, + yearsOrder, + input, + actionArea, + invalid: originInvalid, + disableLastDateClickClose, + calendars, + xs, + sm, + md, + lg, + xl, + ...props + }, + forwardedRef, + ) => { + const xsSplit = useMemo(() => splitResponsiveProps(xs, calendarKeys), [xs]); + const smSplit = useMemo(() => splitResponsiveProps(sm, calendarKeys), [sm]); + const mdSplit = useMemo(() => splitResponsiveProps(md, calendarKeys), [md]); + const lgSplit = useMemo(() => splitResponsiveProps(lg, calendarKeys), [lg]); + const xlSplit = useMemo(() => splitResponsiveProps(xl, calendarKeys), [xl]); + + const ref = useRef(null); + const composedRefs = useComposedRefs(forwardedRef, ref); + + const [open, setOpen] = useControllableState({ + prop: originOpen, + defaultProp: defaultOpen ?? false, + onChange: onOpenChange, + }); + + const [value, setValue] = useControllableState({ + prop: originValue, + defaultProp: defaultValue ?? DEFAULT_RANGE_VALUE, + onChange: onChange, + }); + + const initialValue = useRef(value); + + const { + loop = true, + trapped, + trappedContent = true, + disableFocusScope, + onMountAutoFocus, + onUnmountAutoFocus, + position = 'bottom-start', + offset = 8, + sx: contentSx, + ...otherContentProps + } = contentProps || {}; + + const Component = input ?? DateRangePickerField; + + const { + inputRef, + inputValue, + focusedSection, + handlePaste, + handleFocus, + handleClick, + handleBlur, + handleKeyDown, + handleValueChange, + handleInputValueChange, + } = useDateRangeField({ + value, + format, + locale, + timezone, + setValue, + readOnly, + disabled, + }); + + const invalid = originInvalid || (!onChange && isInvalidDateRange(value)); + + const handleChangeCompleteCallback = useCallbackRef(onChangeComplete); + + const handleChangeComplete = useCallback( + (v: DateRangeType) => { + handleValueChange(v); + handleChangeCompleteCallback(v); + + if (!disableLastDateClickClose) { + setOpen(false); + } + }, + [ + handleValueChange, + handleChangeCompleteCallback, + disableLastDateClickClose, + setOpen, + ], + ); + + const handleChangeCompleteActionArea = useCallback( + (v: DateRangeType | DateType) => { + handleChangeComplete(v as DateRangeType); + setOpen(false); + }, + [handleChangeComplete, setOpen], + ); + + const composedInputRef = useComposedRefs(originInputRef, inputRef); + + const resolvedPlaceholder = placeholder ?? `${format} - ${format}`; + + useEffect(() => { + if (open) { + initialValue.current = value; + } else { + handleBlur(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + + {}} + aria-haspopup="dialog" + aria-expanded={open} + data-role="date-range-picker-field" + role="combobox" + {...props} + {...({ + xs: xsSplit.rest, + sm: smSplit.rest, + md: mdSplit.rest, + lg: lgSplit.rest, + xl: xlSplit.rest, + type: 'text', + autoComplete: 'off', + readOnly, + disabled, + placeholder: resolvedPlaceholder, + invalid, + inputMode: focusedSection?.type, + onFocus: composeEventHandlers(props.onFocus, handleFocus), + onClick: composeEventHandlers(props.onClick, handleClick), + onKeyDown: composeEventHandlers(props.onKeyDown, handleKeyDown), + onBlur: composeEventHandlers(props.onBlur, handleBlur), + onPaste: composeEventHandlers(props.onPaste, handlePaste), + value: inputValue, + inputRef: composedInputRef, + trailingContent: ( + <> + {props.trailingContent} + + { + e.stopPropagation(); + handleInputValueChange(); + setOpen((prev) => !prev); + }} + > + + + + + ), + } as unknown as SlotProps)} + > + + + + {open && ( + + + { + if ( + ref.current?.contains(e.target as HTMLElement) && + (e.target as HTMLElement).closest( + '[data-role="date-range-picker-calendar-icon"]', + ) + ) { + e.preventDefault(); + } + }} + onDismiss={() => { + setOpen(false); + }} + > + + + + + {actionArea} + + + + + + )} + + ); + }, +); + +DateRangePicker.displayName = 'DateRangePicker'; + +const DateRangePickerField = forwardRef< + HTMLDivElement, + DefaultComponentPropsInternal +>(({ inputRef, ...props }, ref) => ( + +)); + +DateRangePickerField.displayName = 'DateRangePickerField'; + +export { DateRangePicker }; + +export type { DateRangePickerProps, DateRangePickerFieldProps }; diff --git a/packages/wds/src/components/date-range-picker/style.ts b/packages/wds/src/components/date-range-picker/style.ts new file mode 100644 index 000000000..cdfcf33fe --- /dev/null +++ b/packages/wds/src/components/date-range-picker/style.ts @@ -0,0 +1,11 @@ +import { css } from '@wanteddev/wds-engine'; + +import type { Theme } from '@wanteddev/wds-engine'; + +export const dateRangePopperStyle = (theme: Theme) => css` + background-color: ${theme.semantic.background.elevated.normal}; + box-shadow: ${theme.semantic.elevation.shadow.normal.small}; + border-radius: 12px; + border: 1px solid ${theme.semantic.line.solid.neutral}; + overflow: hidden; +`; diff --git a/packages/wds/src/components/date-range-picker/types.ts b/packages/wds/src/components/date-range-picker/types.ts new file mode 100644 index 000000000..9c617d718 --- /dev/null +++ b/packages/wds/src/components/date-range-picker/types.ts @@ -0,0 +1,78 @@ +import type { + TextFieldProps, + TextFieldResponsiveProps, +} from '../text-field/types'; +import type { TextField } from '../text-field'; +import type { + BreakPoint, + DefaultComponentPropsInternal, + Merge, + ResponsiveProps, + WithSxProps, +} from '@wanteddev/wds-engine'; +import type { + ComponentProps, + ComponentPropsWithoutRef, + ElementType, + ReactNode, + Ref, +} from 'react'; +import type { PopperContent } from '../popper'; +import type { FocusScope } from '../focus-scope'; +import type { + DateRangeCalendar, + DateRangeCalendarProps, +} from '../date-range-calendar'; + +type DateRangePickerResponsiveProps = ResponsiveProps< + NonNullable & + NonNullable +>; + +export type DateRangePickerProps = Merge< + Merge< + { + /** Whether the date range picker is open. */ + open?: boolean; + /** Whether the date range picker is open by default. */ + defaultOpen?: boolean; + /** Callback function when the open state changes. */ + onOpenChange?: (state: boolean) => void; + /** The props for the content. */ + contentProps?: WithSxProps< + Merge< + ComponentProps, + ComponentPropsWithoutRef + > + >; + /** The format of the date. */ + format?: string; + /** The ref for the input. */ + inputRef?: Ref; + /** + * Custom input component. + */ + input?: ElementType; + /** The action area of the date range picker. */ + actionArea?: ReactNode; + /** + * When the range is completed, the popover is not closed. + */ + disableLastDateClickClose?: boolean; + }, + Omit, keyof BreakPoint> & + Omit + >, + DateRangePickerResponsiveProps +>; + +export type DateRangePickerFieldProps = Merge< + { + ref?: Ref; + inputRef?: Ref; + }, + DefaultComponentPropsInternal< + Omit, 'wrapperRef'>, + 'input' + > +>; diff --git a/packages/wds/src/components/index.ts b/packages/wds/src/components/index.ts index e9e89eed8..9903c8cd0 100644 --- a/packages/wds/src/components/index.ts +++ b/packages/wds/src/components/index.ts @@ -16,6 +16,8 @@ export * from './filter-button'; export * from './content-badge'; export * from './date-calendar'; export * from './date-picker'; +export * from './date-range-calendar'; +export * from './date-range-picker'; export * from './alert'; export * from './dismissable-layer'; export * from './divider'; diff --git a/packages/wds/src/components/modal/hooks.ts b/packages/wds/src/components/modal/hooks.ts index 0eaced5fa..12022b15c 100644 --- a/packages/wds/src/components/modal/hooks.ts +++ b/packages/wds/src/components/modal/hooks.ts @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useTheme } from '@wanteddev/wds-engine'; +import { useMedia } from '../../hooks/internal/use-media'; import { getPreviousValue } from '../../utils/internal/responsive-props'; import { @@ -312,52 +313,3 @@ export const useDraggable = ({ onTouchStart: onMouseDown, }; }; - -const useMedia = ( - queries: Array, - values: Array, - defaultValue: T, -): T => { - const [value, setValue] = useState(defaultValue); - - const mediaQueryLists = useMemo(() => { - if (typeof window === 'undefined') { - return []; - } - - return queries.map(function (q) { - return window.matchMedia(q); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, Object.values(queries)); - - const getValue = useCallback(() => { - if (typeof window === 'undefined') { - return defaultValue; - } - - const index = mediaQueryLists.findIndex((mql) => mql.matches); - - return typeof values[index] !== 'undefined' - ? (values[index] as T) - : defaultValue; - }, [defaultValue, values, mediaQueryLists]); - - useEffect(() => { - const handler = () => { - setValue(getValue); - }; - - mediaQueryLists.forEach((mql) => { - handler(); - mql.addEventListener('change', handler); - }); - - return () => - mediaQueryLists.forEach((mql) => - mql.removeEventListener('change', handler), - ); - }, [mediaQueryLists, getValue]); - - return value; -}; diff --git a/packages/wds/src/components/picker-action-area/contexts.ts b/packages/wds/src/components/picker-action-area/contexts.ts index 1162401dc..00d8ea576 100644 --- a/packages/wds/src/components/picker-action-area/contexts.ts +++ b/packages/wds/src/components/picker-action-area/contexts.ts @@ -2,13 +2,17 @@ import { createContext } from '@radix-ui/react-context'; import type { RefObject } from 'react'; import type { DateType } from '../date-picker'; +import type { DateRangeType } from '../date-range-calendar/types'; type PickerActionAreaContextValue = { timezone?: string; - value: DateType; - initialValue: RefObject; - onChangeComplete: (value: DateType) => void; + value: DateType | DateRangeType; + initialValue: RefObject; + onChangeComplete: (value: DateType | DateRangeType) => void; + mode?: 'single' | 'range'; }; export const [PickerActionAreaProvider, usePickerActionAreaContext] = - createContext('DatePicker OR TimePicker'); + createContext( + 'DatePicker OR DateRangePicker OR TimePicker', + ); diff --git a/packages/wds/src/components/picker-action-area/index.tsx b/packages/wds/src/components/picker-action-area/index.tsx index cf0fb8aac..249b0dce0 100644 --- a/packages/wds/src/components/picker-action-area/index.tsx +++ b/packages/wds/src/components/picker-action-area/index.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from 'react'; +import { forwardRef, useEffect } from 'react'; import { composeEventHandlers } from '@radix-ui/primitive'; import { ActionArea } from '../action-area'; @@ -44,9 +44,23 @@ const PickerActionAreaButton = forwardRef( }: PolymorphicPropsInternal, ref: ForwardedRef, ) => { - const { initialValue, value, timezone, onChangeComplete } = + const { initialValue, value, timezone, onChangeComplete, mode } = usePickerActionAreaContext(PICKER_ACTION_AREA_BUTTON_NAME); + const isRange = mode === 'range'; + + useEffect(() => { + if ( + variant === 'now' && + isRange && + process.env.NODE_ENV !== 'production' + ) { + console.warn( + '[WDS] PickerActionAreaButton: "now" variant is not supported in DateRangePicker. Use "accept", "cancel", or "reset" instead.', + ); + } + }, [variant, isRange]); + switch (variant) { case 'now': return ( @@ -54,16 +68,18 @@ const PickerActionAreaButton = forwardRef( ref={ref} color="assistive" size="small" + disabled={isRange} {...props} - onClick={composeEventHandlers(props.onClick, () => { - onChangeComplete(dateTypeToDateObject(new Date(), timezone)); - })} - sx={[ - { - margin: '0px 6px', - }, - props.sx, - ]} + onClick={ + isRange + ? undefined + : composeEventHandlers(props.onClick, () => { + onChangeComplete( + dateTypeToDateObject(new Date(), timezone), + ); + }) + } + sx={[{ margin: '0px 6px' }, props.sx]} /> ); case 'cancel': @@ -76,12 +92,7 @@ const PickerActionAreaButton = forwardRef( onClick={composeEventHandlers(props.onClick, () => { onChangeComplete(initialValue.current); })} - sx={[ - { - margin: '0px 6px', - }, - props.sx, - ]} + sx={[{ margin: '0px 6px' }, props.sx]} /> ); case 'reset': @@ -92,14 +103,9 @@ const PickerActionAreaButton = forwardRef( size="small" {...props} onClick={composeEventHandlers(props.onClick, () => { - onChangeComplete(undefined); + onChangeComplete(isRange ? [undefined, undefined] : undefined); })} - sx={[ - { - margin: '0px 6px', - }, - props.sx, - ]} + sx={[{ margin: '0px 6px' }, props.sx]} /> ); case 'accept': @@ -112,12 +118,7 @@ const PickerActionAreaButton = forwardRef( onClick={composeEventHandlers(props.onClick, () => { onChangeComplete(value); })} - sx={[ - { - margin: '0px 6px', - }, - props.sx, - ]} + sx={[{ margin: '0px 6px' }, props.sx]} /> ); default: @@ -127,12 +128,7 @@ const PickerActionAreaButton = forwardRef( color="assistive" size="small" {...props} - sx={[ - { - margin: '0px 6px', - }, - props.sx, - ]} + sx={[{ margin: '0px 6px' }, props.sx]} /> ); } diff --git a/packages/wds/src/components/text-field/style.ts b/packages/wds/src/components/text-field/style.ts index 5dac01ce4..d708e6795 100644 --- a/packages/wds/src/components/text-field/style.ts +++ b/packages/wds/src/components/text-field/style.ts @@ -109,7 +109,12 @@ export const textFieldWrapperStyle = :has( input[data-role='date-picker-field'][aria-expanded='true'] ), - :has(input[data-role='time-picker-field'][aria-expanded='true']) + :has( + input[data-role='time-picker-field'][aria-expanded='true'] + ), + :has( + input[data-role='date-range-picker-field'][aria-expanded='true'] + ) ) { ${invalid ? css` @@ -203,7 +208,8 @@ export const textFieldWrapperStyle = @supports selector(:has(*)) { &:where( :has(input[data-role='date-picker-field']), - :has(input[data-role='time-picker-field']) + :has(input[data-role='time-picker-field']), + :has(input[data-role='date-range-picker-field']) ) { [data-role='text-field-reset'], [data-role='text-field-invalid'], diff --git a/packages/wds/src/components/time-picker/index.tsx b/packages/wds/src/components/time-picker/index.tsx index e2ecbc852..a13e1f6b1 100644 --- a/packages/wds/src/components/time-picker/index.tsx +++ b/packages/wds/src/components/time-picker/index.tsx @@ -24,6 +24,7 @@ import { timePickerStyle } from './style'; import type { SlotProps } from '@radix-ui/react-slot'; import type { TimePickerFieldProps, TimePickerProps } from './types'; import type { DateType } from '../date-picker'; +import type { DateRangeType } from '../date-range-calendar/types'; extendDayjs(); @@ -144,8 +145,8 @@ const TimePicker = forwardRef< ); const handleChangeCompleteActionArea = useCallback( - (v: DateType) => { - handleChangeComplete(v); + (v: DateType | DateRangeType) => { + handleChangeComplete(v as DateType); setOpen(false); }, [handleChangeComplete, setOpen], diff --git a/packages/wds/src/hooks/internal/use-media.ts b/packages/wds/src/hooks/internal/use-media.ts new file mode 100644 index 000000000..f06528383 --- /dev/null +++ b/packages/wds/src/hooks/internal/use-media.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export const useMedia = ( + queries: Array, + values: Array, + defaultValue: T, +): T => { + const valuesRef = useRef(values); + valuesRef.current = values; + + const defaultValueRef = useRef(defaultValue); + defaultValueRef.current = defaultValue; + + const mediaQueryLists = useMemo(() => { + if (typeof window === 'undefined') { + return []; + } + + return queries.map((q) => window.matchMedia(q)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, Object.values(queries)); + + const getValue = useCallback(() => { + if (typeof window === 'undefined') { + return defaultValueRef.current; + } + + const index = mediaQueryLists.findIndex((mql) => mql.matches); + + return typeof valuesRef.current[index] !== 'undefined' + ? (valuesRef.current[index] as T) + : defaultValueRef.current; + }, [mediaQueryLists]); + + const [value, setValue] = useState(getValue); + + useEffect(() => { + const handler = () => setValue(getValue); + handler(); + mediaQueryLists.forEach((mql) => mql.addEventListener('change', handler)); + return () => + mediaQueryLists.forEach((mql) => + mql.removeEventListener('change', handler), + ); + }, [mediaQueryLists, getValue]); + + return value; +}; diff --git a/packages/wds/src/utils/internal/responsive-props.ts b/packages/wds/src/utils/internal/responsive-props.ts index c7f636f83..75f959673 100644 --- a/packages/wds/src/utils/internal/responsive-props.ts +++ b/packages/wds/src/utils/internal/responsive-props.ts @@ -121,3 +121,36 @@ export const getPreviousValue = ( return objectPath.get(params.xs || {}, key as string) ?? defaultValue; } }; + +/** + * Splits responsive breakpoint props by specified keys. + * Returns `picked` containing only the specified keys and `rest` containing everything else. + */ +export const splitResponsiveProps = < + T extends Record, + K extends keyof T, +>( + bp: T | undefined, + keys: Array, +): { picked: Pick | undefined; rest: Omit | undefined } => { + if (!bp) return { picked: undefined, rest: undefined }; + + const picked = {} as Record; + const rest = {} as Record; + + for (const [k, v] of Object.entries(bp)) { + if (keys.includes(k as K)) { + picked[k] = v; + } else { + rest[k] = v; + } + } + + const hasPicked = Object.keys(picked).length > 0; + const hasRest = Object.keys(rest).length > 0; + + return { + picked: hasPicked ? (picked as Pick) : undefined, + rest: hasRest ? (rest as Omit) : undefined, + }; +}; From d235cec45868a13a54f171eaff127b3cbc03258f Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Wed, 18 Mar 2026 15:51:58 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore(wds-mcp):=20date-range-picker=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20URL=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds-mcp/src/helpers/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wds-mcp/src/helpers/index.ts b/packages/wds-mcp/src/helpers/index.ts index f944bbb24..ca61e31f4 100644 --- a/packages/wds-mcp/src/helpers/index.ts +++ b/packages/wds-mcp/src/helpers/index.ts @@ -145,6 +145,7 @@ export const getComponentUrl = async ( ) => { const componentSlug = kebabCase(componentName); const componentPathMap: Record = { + 'date-range-picker': 'date-picker', list: 'list-cell', stepper: 'progress-tracker', 'card-list': 'card', From aab72e663472b1bca96eec0e01b7b7b479b8faef Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Thu, 19 Mar 2026 09:24:37 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(wds):=20date=20range=20calendar=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EB=B0=8F=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - month/year panel ARIA: radiogroup/radio → grid/row/gridcell, aria-checked → aria-selected - month view 키보드: 화살표 경계 넘을 때 이전/다음 년도 이동, PageUp/PageDown 년도 단위 이동 - month view disabled 월 포커스 방지: findClosestEnableDate로 enabled 월로 clamp - 스타일 중복 제거: rangeCellBaseStyle, rangeItemBaseStyle 추출 - RangeDayItemProps/RangeDateItemProps → RangeItemProps 통합 - PanelHeaderButton → PanelHeaderLabel (Typography) 변경 - Grid/GridItem → FlexBox + role="row" 구조로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/date-calendar/index.tsx | 2 + .../wds/src/components/date-calendar/style.ts | 2 - .../date-range-calendar/index.test.tsx | 26 +- .../components/date-range-calendar/index.tsx | 425 +++++++++++------- .../components/date-range-calendar/style.ts | 29 +- .../components/date-range-calendar/types.ts | 2 - .../date-range-picker/index.test.tsx | 3 +- 7 files changed, 277 insertions(+), 212 deletions(-) diff --git a/packages/wds/src/components/date-calendar/index.tsx b/packages/wds/src/components/date-calendar/index.tsx index 15e142cdd..e6deef644 100644 --- a/packages/wds/src/components/date-calendar/index.tsx +++ b/packages/wds/src/components/date-calendar/index.tsx @@ -1118,6 +1118,8 @@ const DayCalendar = memo( ref={ref} role="rowgroup" {...props} + columnGap="0px" + rowGap="2px" sx={[dateYearMonthWrapperStyle, props.sx]} > {dayRangeRow.map((days, idx) => ( diff --git a/packages/wds/src/components/date-calendar/style.ts b/packages/wds/src/components/date-calendar/style.ts index 2e8836850..c9f8b1df1 100644 --- a/packages/wds/src/components/date-calendar/style.ts +++ b/packages/wds/src/components/date-calendar/style.ts @@ -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` diff --git a/packages/wds/src/components/date-range-calendar/index.test.tsx b/packages/wds/src/components/date-range-calendar/index.test.tsx index 74bb02c8c..de0daa200 100644 --- a/packages/wds/src/components/date-range-calendar/index.test.tsx +++ b/packages/wds/src/components/date-range-calendar/index.test.tsx @@ -141,7 +141,7 @@ describe('when given date range calendar component', () => { it('should render month view', () => { render(); - expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getByRole('grid')).toBeInTheDocument(); expect(screen.getByText('Jan')).toBeInTheDocument(); expect(screen.getByText('Dec')).toBeInTheDocument(); }); @@ -172,10 +172,8 @@ describe('when given date range calendar component', () => { it('should render year view', () => { render(); - expect(screen.getByRole('radiogroup')).toBeInTheDocument(); - expect( - screen.getByRole('radio', { name: '2025 Year' }), - ).toBeInTheDocument(); + expect(screen.getByRole('grid')).toBeInTheDocument(); + expect(screen.getByRole('gridcell', { name: '2025' })).toBeInTheDocument(); }); it('should handle year range selection', () => { @@ -191,10 +189,10 @@ describe('when given date range calendar component', () => { />, ); - fireEvent.click(screen.getByRole('radio', { name: '2024 Year' })); + fireEvent.click(screen.getByRole('gridcell', { name: '2024' })); expect(onChange).toHaveBeenCalledWith([expect.any(Date), undefined]); - fireEvent.click(screen.getByRole('radio', { name: '2026 Year' })); + fireEvent.click(screen.getByRole('gridcell', { name: '2026' })); expect(onChangeComplete).toHaveBeenCalledWith([ expect.any(Date), expect.any(Date), @@ -204,13 +202,13 @@ describe('when given date range calendar component', () => { it('should handle keyboard navigation in year view', async () => { render(); - const year2025 = screen.getByRole('radio', { name: '2025 Year' }); + const year2025 = screen.getByRole('gridcell', { name: '2025' }); year2025.focus(); fireEvent.keyDown(year2025, { key: 'ArrowRight' }); await waitFor(() => { - expect(screen.getByRole('radio', { name: '2026 Year' })).toHaveFocus(); + expect(screen.getByRole('gridcell', { name: '2026' })).toHaveFocus(); }); }); @@ -256,12 +254,18 @@ describe('when given date range calendar component', () => { it('should pass accessibility tests for month view', async () => { render(); - expect(await axe(screen.getByRole('radiogroup'))).toHaveNoViolations(); + const grids = screen.getAllByRole('grid'); + for (const grid of grids) { + expect(await axe(grid)).toHaveNoViolations(); + } }); it('should pass accessibility tests for year view', async () => { render(); - expect(await axe(screen.getByRole('radiogroup'))).toHaveNoViolations(); + const grids = screen.getAllByRole('grid'); + for (const grid of grids) { + expect(await axe(grid)).toHaveNoViolations(); + } }); }); diff --git a/packages/wds/src/components/date-range-calendar/index.tsx b/packages/wds/src/components/date-range-calendar/index.tsx index 15e5786f4..8f48f6678 100644 --- a/packages/wds/src/components/date-range-calendar/index.tsx +++ b/packages/wds/src/components/date-range-calendar/index.tsx @@ -21,8 +21,6 @@ import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { FlexBox } from '../flex-box'; import { IconButton } from '../icon-button'; -import { Grid } from '../grid'; -import { GridItem } from '../grid-item'; import { WithInteraction } from '../with-interaction'; import { ScrollArea } from '../scroll-area'; import { Typography } from '../typography'; @@ -53,7 +51,6 @@ import { } from './helpers'; import { rangeCalendarContainerStyle, - rangeDateItemStyle, rangeDayCellStyle, rangeDayItemStyle, rangeGridWrapperStyle, @@ -79,7 +76,6 @@ import type { DateRangeCalendarProps, DateRangeType, RangeDateItemProps, - RangeDayItemProps, } from './types'; extendDayjs(); @@ -697,8 +693,8 @@ const RangeDayGrid = memo(({ panelMonth, panelIndex }: RangeDayGridProps) => { data-range-start={isRangeStart ? true : undefined} data-range-end={isRangeEnd ? true : undefined} > - { }} > {day.label} - + ); })} @@ -763,8 +759,8 @@ const RangeMonthPanel = memo(() => { [rangeValue, hoveredDate, activePosition, timezone], ); - const monthRange = useMemo(() => { - return new Array(12).fill(0).map((_, i) => { + const { monthRange, monthRows } = useMemo(() => { + const range = new Array(12).fill(0).map((_, i) => { const minDate = dayjsTimezone( dayjs(min ?? ACCESSIBLE_MIN_DATE), timezone, @@ -792,6 +788,15 @@ const RangeMonthPanel = memo(() => { dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()), }; }); + + const rows = new Array(Math.ceil(range.length / 3)).fill(0).map((_, i) => { + return range.slice(i * 3, (i + 1) * 3); + }); + + return { + monthRange: range, + monthRows: rows, + }; }, [min, timezone, max, locale, defaultSelectedDate]); const [focusedIdx, setFocusedIdx] = useState( @@ -863,19 +868,48 @@ const RangeMonthPanel = memo(() => { e.currentTarget.getAttribute('data-month-index') ?? '0', ); let newMonth: number; + let yearDelta = 0; switch (e.key) { case 'ArrowUp': - newMonth = Math.max(0, currentMonth - 3); + if (currentMonth < 3) { + yearDelta = -1; + newMonth = currentMonth + 9; + } else { + newMonth = currentMonth - 3; + } break; case 'ArrowDown': - newMonth = Math.min(11, currentMonth + 3); + if (currentMonth > 8) { + yearDelta = 1; + newMonth = currentMonth - 9; + } else { + newMonth = currentMonth + 3; + } break; case 'ArrowLeft': - newMonth = Math.max(0, currentMonth - 1); + if (currentMonth === 0) { + yearDelta = -1; + newMonth = 11; + } else { + newMonth = currentMonth - 1; + } break; case 'ArrowRight': - newMonth = Math.min(11, currentMonth + 1); + if (currentMonth === 11) { + yearDelta = 1; + newMonth = 0; + } else { + newMonth = currentMonth + 1; + } + break; + case 'PageUp': + yearDelta = -1; + newMonth = currentMonth; + break; + case 'PageDown': + yearDelta = 1; + newMonth = currentMonth; break; case 'Home': newMonth = 0; @@ -889,23 +923,78 @@ const RangeMonthPanel = memo(() => { e.preventDefault(); - setFocusedIdx(monthRange.findIndex((v) => v.value === newMonth)); + if (yearDelta !== 0) { + const newDate = dayjsTimezone(dayjs(defaultSelectedDate), timezone).add( + yearDelta, + 'year', + ); - const monthDate = dateTypeToDateObject( - dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( - 'month', - newMonth, - ), - timezone, - ); - setHoveredDate(monthDate); + const clamped = findClosestEnableDate({ + min, + max, + value: dateTypeToDateObject(newDate.set('month', newMonth), timezone), + timezone, + }); - const mv = `${dayjsTimezone(dayjs(defaultSelectedDate), timezone).year()}-${String(newMonth + 1).padStart(2, '0')}`; - requestAnimationFrame(() => { - focusRangeDate('month', mv, containerRef); - }); + setDefaultSelectedDate( + dateTypeToDateObject( + dayjsTimezone(dayjs(clamped), timezone).startOf('month'), + timezone, + ), + ); + + const clampedMonth = dayjsTimezone(dayjs(clamped), timezone).month(); + setFocusedIdx(monthRange.findIndex((v) => v.value === clampedMonth)); + + if (!disabled && !readOnly) { + setHoveredDate(clamped); + } + + const clampedYear = dayjsTimezone(dayjs(clamped), timezone).year(); + const mv = `${clampedYear}-${String(clampedMonth + 1).padStart(2, '0')}`; + requestAnimationFrame(() => { + focusRangeDate('month', mv, containerRef); + }); + } else { + const clamped = findClosestEnableDate({ + min, + max, + timezone, + value: dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( + 'month', + newMonth, + ), + timezone, + ), + }); + const clampedMonth = dayjsTimezone(dayjs(clamped), timezone).month(); + const clampedYear = dayjsTimezone(dayjs(clamped), timezone).year(); + + setFocusedIdx(monthRange.findIndex((v) => v.value === clampedMonth)); + + if (!disabled && !readOnly) { + setHoveredDate(clamped); + } + + const mv = `${clampedYear}-${String(clampedMonth + 1).padStart(2, '0')}`; + requestAnimationFrame(() => { + focusRangeDate('month', mv, containerRef); + }); + } }, - [containerRef, defaultSelectedDate, monthRange, setHoveredDate, timezone], + [ + containerRef, + defaultSelectedDate, + disabled, + max, + min, + monthRange, + readOnly, + setDefaultSelectedDate, + setHoveredDate, + timezone, + ], ); return ( @@ -917,7 +1006,6 @@ const RangeMonthPanel = memo(() => { { - - - {monthRange.map((month, i) => { - const monthDate = dateTypeToDateObject( - dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( - 'month', - month.value, - ), - timezone, - ); - const year = dayjsTimezone( - dayjs(defaultSelectedDate), - timezone, - ).year(); - const monthVal = `${year}-${String(month.value + 1).padStart(2, '0')}`; - - const isRangeStart = isSameDateForView( - monthDate, - displayRange[0], - 'month', - timezone, - ); - const isRangeEnd = isSameDateForView( - monthDate, - displayRange[1], - 'month', - timezone, - ); - const isInRange = isDateInRangeForView( - monthDate, - displayRange[0], - displayRange[1], - 'month', - timezone, - ); - - return ( - - - { - if (!disabled && !readOnly) setHoveredDate(monthDate); - }} + + + {monthRows.map((rowMonths, rowIdx) => ( + + {rowMonths.map((month, colIndex) => { + const monthDate = dateTypeToDateObject( + dayjsTimezone(dayjs(defaultSelectedDate), timezone).set( + 'month', + month.value, + ), + timezone, + ); + const year = dayjsTimezone( + dayjs(defaultSelectedDate), + timezone, + ).year(); + const monthVal = `${year}-${String(month.value + 1).padStart(2, '0')}`; + + const isRangeStart = isSameDateForView( + monthDate, + displayRange[0], + 'month', + timezone, + ); + const isRangeEnd = isSameDateForView( + monthDate, + displayRange[1], + 'month', + timezone, + ); + const isInRange = isDateInRangeForView( + monthDate, + displayRange[0], + displayRange[1], + 'month', + timezone, + ); + + return ( + - {month.label} - - - - ); - })} - + { + if (!disabled && !readOnly) setHoveredDate(monthDate); + }} + > + {month.label} + + + ); + })} + + ))} + @@ -1092,7 +1188,7 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { [rangeValue, hoveredDate, activePosition, timezone], ); - const yearRange = useMemo(() => { + const { yearRange, yearRows } = useMemo(() => { const startDate = dayjsTimezone( dayjs(min ?? ACCESSIBLE_MIN_DATE), timezone, @@ -1106,7 +1202,15 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { current = current.add(1, 'year'); } - return yearsOrder === 'asc' ? years : years.reverse(); + const range = yearsOrder === 'asc' ? years : years.reverse(); + const rows = new Array(Math.ceil(range.length / 3)).fill(0).map((_, i) => { + return range.slice(i * 3, (i + 1) * 3); + }); + + return { + yearRange: range, + yearRows: rows, + }; }, [min, timezone, max, yearsOrder]); const [focusedIdx, setFocusedIdx] = useState(yearRange.length > 0 ? 0 : -1); @@ -1253,12 +1357,7 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { alignItems="flex-start" sx={rangePanelStyle} > - + { - - - {yearRange.map((year, i) => { - const yearDate = dateTypeToDateObject( - dayjs(defaultSelectedDate).set('year', year), - timezone, - ); - const yearVal = String(year); - - const isRangeStart = isSameDateForView( - yearDate, - displayRange[0], - 'year', - timezone, - ); - const isRangeEnd = isSameDateForView( - yearDate, - displayRange[1], - 'year', - timezone, - ); - const isInRange = isDateInRangeForView( - yearDate, - displayRange[0], - displayRange[1], - 'year', - timezone, - ); - - return ( - + + {yearRows.map((rowYears, rowIdx) => ( + + {rowYears.map((year, colIndex) => { + const yearDate = dateTypeToDateObject( + dayjs(defaultSelectedDate).set('year', year), + timezone, + ); + const yearVal = String(year); + + const isRangeStart = isSameDateForView( + yearDate, + displayRange[0], + 'year', + timezone, + ); + const isRangeEnd = isSameDateForView( + yearDate, + displayRange[1], + 'year', + timezone, + ); + const isInRange = isDateInRangeForView( + yearDate, + displayRange[0], + displayRange[1], + 'year', + timezone, + ); + + const yearIdx = yearRange.indexOf(year); + + return ( { data-year={yearVal} isCurrent={now.year() === year} isActive={isRangeStart || isRangeEnd} - aria-label={`${year} Year`} + aria-label={year.toString()} + aria-colindex={colIndex + 1} onKeyDown={handleKeyDown} - tabIndex={focusedIdx === i ? 0 : -1} + tabIndex={focusedIdx === yearIdx ? 0 : -1} onMouseEnter={() => { if (!disabled && !readOnly) setHoveredDate(yearDate); }} @@ -1325,10 +1437,10 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { {year} - - ); - })} - + ); + })} + + ))} @@ -1351,31 +1463,6 @@ const PanelHeaderLabel = memo(({ label }: PanelHeaderLabelProps) => { PanelHeaderLabel.displayName = 'PanelHeaderLabel'; -const RangeDayItem = forwardRef< - HTMLButtonElement, - DefaultComponentPropsInternal ->(({ disabled, isCurrent, isOtherMonth, isActive, ...props }, ref) => { - return ( - - - - ); -}); - -RangeDayItem.displayName = 'RangeDayItem'; - const RangeDateItem = memo( forwardRef< HTMLButtonElement, @@ -1387,14 +1474,14 @@ const RangeDateItem = memo( as="button" disabled={disabled} ref={ref} - role="radio" + role="gridcell" type="button" {...props} - aria-checked={isActive} + aria-selected={isActive} aria-disabled={disabled} aria-current={isCurrent ? 'date' : undefined} data-other-month={isOtherMonth} - sx={[rangeDateItemStyle, props.sx]} + sx={[rangeDayItemStyle, props.sx]} /> ); diff --git a/packages/wds/src/components/date-range-calendar/style.ts b/packages/wds/src/components/date-range-calendar/style.ts index f55708c34..4833b3597 100644 --- a/packages/wds/src/components/date-range-calendar/style.ts +++ b/packages/wds/src/components/date-range-calendar/style.ts @@ -59,7 +59,6 @@ export const rangeGridWrapperStyle = css` column-gap: 0px; `; -/** Shared range cell base — holds the range band ::before pseudo-element */ const rangeCellBaseStyle = (theme: Theme) => css` position: relative; display: flex; @@ -106,17 +105,14 @@ const rangeCellBaseStyle = (theme: Theme) => css` } `; -/** Day cell wrapper — holds the range band ::before and the pill button */ export const rangeDayCellStyle = (theme: Theme) => css` ${rangeCellBaseStyle(theme)} width: 36px; `; -/** Month/Year cell wrapper for range band */ export const rangeMonthYearCellStyle = rangeCellBaseStyle; -/** Shared range item base — color, border, padding, typography, states */ -const rangeItemBaseStyle = (theme: Theme) => css` +export const rangeDayItemStyle = (theme: Theme) => css` color: ${theme.semantic.label.normal}; border: none; padding: 7px 0px; @@ -124,6 +120,8 @@ const rangeItemBaseStyle = (theme: Theme) => css` background-color: transparent; position: relative; z-index: 1; + border-radius: 8px; + height: fit-content; ${typographyStyle('label2', 'medium')} @@ -165,12 +163,6 @@ const rangeItemBaseStyle = (theme: Theme) => css` opacity: 0.06; } } -`; - -/** Day button inside the range cell */ -export const rangeDayItemStyle = (theme: Theme) => css` - ${rangeItemBaseStyle(theme)} - border-radius: 10000px; &[aria-selected='true'] { color: ${theme.semantic.static.white}; @@ -182,18 +174,3 @@ export const rangeDayItemStyle = (theme: Theme) => css` } } `; - -/** Month/Year button inside the range cell */ -export const rangeDateItemStyle = (theme: Theme) => css` - ${rangeItemBaseStyle(theme)} - border-radius: 8px; - - &[aria-checked='true'] { - color: ${theme.semantic.static.white}; - background-color: ${theme.semantic.primary.normal}; - &:disabled { - color: ${addOpacity(theme.semantic.static.white, theme.opacity[43])}; - background-color: ${theme.semantic.primary.normal}; - } - } -`; diff --git a/packages/wds/src/components/date-range-calendar/types.ts b/packages/wds/src/components/date-range-calendar/types.ts index addfcb16e..b26df5fd1 100644 --- a/packages/wds/src/components/date-range-calendar/types.ts +++ b/packages/wds/src/components/date-range-calendar/types.ts @@ -50,6 +50,4 @@ export type RangeItemProps = WithSxProps<{ isInRange?: boolean; }>; -export type RangeDayItemProps = RangeItemProps; - export type RangeDateItemProps = RangeItemProps; diff --git a/packages/wds/src/components/date-range-picker/index.test.tsx b/packages/wds/src/components/date-range-picker/index.test.tsx index 903dfc1ed..04222d804 100644 --- a/packages/wds/src/components/date-range-picker/index.test.tsx +++ b/packages/wds/src/components/date-range-picker/index.test.tsx @@ -5,7 +5,6 @@ import { screen, waitFor, } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DateRangePicker } from '.'; @@ -359,6 +358,6 @@ describe('when given date range picker component', () => { fireEvent.click(screen.getByLabelText('Toggle date range picker')); - expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getAllByRole('grid').length).toBeGreaterThanOrEqual(1); }); }); From 9753d8e0c8db3dd1d01f65343f2fef431dc21668 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Thu, 19 Mar 2026 09:45:57 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(wds):=20date=20range=20calendar=20ARIA?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - grid role을 ScrollArea로 이동, aria-multiselectable 추가 - aria-label을 "Select day/month/year range"로 변경 - year panel handleKeyDown에서 disabled/readOnly 가드 추가 - 테스트 셀렉터를 변경된 ARIA 구조에 맞게 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../date-range-calendar/index.test.tsx | 31 ++-- .../components/date-range-calendar/index.tsx | 158 ++++++++++-------- 2 files changed, 101 insertions(+), 88 deletions(-) diff --git a/packages/wds/src/components/date-range-calendar/index.test.tsx b/packages/wds/src/components/date-range-calendar/index.test.tsx index de0daa200..273ccce7b 100644 --- a/packages/wds/src/components/date-range-calendar/index.test.tsx +++ b/packages/wds/src/components/date-range-calendar/index.test.tsx @@ -35,7 +35,9 @@ describe('when given date range calendar component', () => { it('should render day view by default', () => { render(); - expect(screen.getByRole('grid')).toBeInTheDocument(); + const grid = screen.getByRole('grid', { name: 'Select day range' }); + expect(grid).toBeInTheDocument(); + expect(grid).toHaveAttribute('aria-multiselectable'); expect(screen.getByText('January 2025')).toBeInTheDocument(); }); @@ -118,8 +120,6 @@ describe('when given date range calendar component', () => { />, ); - // February 2025 starts on Saturday, so there are leading empty cells - // Day "1" should appear only once (Feb 1), not as Jan 31's neighbor const dayOnes = screen.getAllByText('1'); expect(dayOnes).toHaveLength(1); }); @@ -130,7 +130,6 @@ describe('when given date range calendar component', () => { const day15 = screen.getByText('15'); day15.focus(); - // ArrowRight moves to next day (uses requestAnimationFrame) fireEvent.keyDown(day15, { key: 'ArrowRight' }); await waitFor(() => { @@ -141,7 +140,9 @@ describe('when given date range calendar component', () => { it('should render month view', () => { render(); - expect(screen.getByRole('grid')).toBeInTheDocument(); + const grid = screen.getByRole('grid', { name: 'Select month range' }); + expect(grid).toBeInTheDocument(); + expect(grid).toHaveAttribute('aria-multiselectable'); expect(screen.getByText('Jan')).toBeInTheDocument(); expect(screen.getByText('Dec')).toBeInTheDocument(); }); @@ -172,7 +173,9 @@ describe('when given date range calendar component', () => { it('should render year view', () => { render(); - expect(screen.getByRole('grid')).toBeInTheDocument(); + const grid = screen.getByRole('grid', { name: 'Select year range' }); + expect(grid).toBeInTheDocument(); + expect(grid).toHaveAttribute('aria-multiselectable'); expect(screen.getByRole('gridcell', { name: '2025' })).toBeInTheDocument(); }); @@ -241,31 +244,31 @@ describe('when given date range calendar component', () => { const headerLabel = screen.getByText('January 2025'); - // Header should be a non-interactive label, not a button expect(headerLabel.closest('button')).toBeNull(); }); it('should pass accessibility tests for day view', async () => { render(); - expect(await axe(screen.getByRole('rowgroup'))).toHaveNoViolations(); + const rowgroup = screen.getByRole('rowgroup'); + expect(await axe(rowgroup)).toHaveNoViolations(); }); it('should pass accessibility tests for month view', async () => { render(); - const grids = screen.getAllByRole('grid'); - for (const grid of grids) { - expect(await axe(grid)).toHaveNoViolations(); + const rows = screen.getAllByRole('row'); + for (const row of rows) { + expect(await axe(row)).toHaveNoViolations(); } }); it('should pass accessibility tests for year view', async () => { render(); - const grids = screen.getAllByRole('grid'); - for (const grid of grids) { - expect(await axe(grid)).toHaveNoViolations(); + const rows = screen.getAllByRole('row'); + for (const row of rows) { + expect(await axe(row)).toHaveNoViolations(); } }); }); diff --git a/packages/wds/src/components/date-range-calendar/index.tsx b/packages/wds/src/components/date-range-calendar/index.tsx index 8f48f6678..b19bca1fd 100644 --- a/packages/wds/src/components/date-range-calendar/index.tsx +++ b/packages/wds/src/components/date-range-calendar/index.tsx @@ -309,7 +309,8 @@ const RangeDayPanel = memo(({ panelIndex }: RangeDayPanelProps) => { sx={rangePanelWrapperStyle} zIndex={11} role="grid" - aria-label={`Select day - ${headerLabel}`} + aria-multiselectable + aria-label="Select day range" > { { - + {monthRows.map((rowMonths, rowIdx) => ( { dayjs(defaultSelectedDate).set('year', clampedYear), timezone, ); - setHoveredDate(yearDate); + if (!disabled && !readOnly) { + setHoveredDate(yearDate); + } requestAnimationFrame(() => { focusRangeDate('year', String(clampedYear), containerRef); @@ -1343,8 +1348,10 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { [ containerRef, defaultSelectedDate, + disabled, max, min, + readOnly, setHoveredDate, timezone, yearRange, @@ -1357,7 +1364,13 @@ const RangeYearPanel = memo(({ yearsOrder = 'asc' }: RangeYearPanelProps) => { alignItems="flex-start" sx={rangePanelStyle} > - + { - - {yearRows.map((rowYears, rowIdx) => ( - - {rowYears.map((year, colIndex) => { - const yearDate = dateTypeToDateObject( - dayjs(defaultSelectedDate).set('year', year), - timezone, - ); - const yearVal = String(year); - - const isRangeStart = isSameDateForView( - yearDate, - displayRange[0], - 'year', - timezone, - ); - const isRangeEnd = isSameDateForView( - yearDate, - displayRange[1], - 'year', - timezone, - ); - const isInRange = isDateInRangeForView( - yearDate, - displayRange[0], - displayRange[1], - 'year', - timezone, - ); - - const yearIdx = yearRange.indexOf(year); - - return ( - - { - if (!disabled && !readOnly) setHoveredDate(yearDate); - }} + + + {yearRows.map((rowYears, rowIdx) => ( + + {rowYears.map((year, colIndex) => { + const yearDate = dateTypeToDateObject( + dayjs(defaultSelectedDate).set('year', year), + timezone, + ); + const yearVal = String(year); + + const isRangeStart = isSameDateForView( + yearDate, + displayRange[0], + 'year', + timezone, + ); + const isRangeEnd = isSameDateForView( + yearDate, + displayRange[1], + 'year', + timezone, + ); + const isInRange = isDateInRangeForView( + yearDate, + displayRange[0], + displayRange[1], + 'year', + timezone, + ); + + const yearIdx = yearRange.indexOf(year); + + return ( + - {year} - - - ); - })} - - ))} + { + if (!disabled && !readOnly) setHoveredDate(yearDate); + }} + > + {year} + + + ); + })} + + ))} + From 9b8d06b0f6cdd881b3dc9abf61f75088c0d3c9c0 Mon Sep 17 00:00:00 2001 From: Sh031224 <1cktmdgh2@gmail.com> Date: Thu, 19 Mar 2026 10:36:48 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(wds):=20useMemo=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=B0=EC=97=B4=EC=9D=84=20theme.breakpoint?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object.values(theme)는 매 렌더마다 새 배열을 생성하여 메모이제이션이 무의미했음. theme.breakpoint는 안정적인 참조이므로 이를 의존성으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wds/src/components/date-range-calendar/index.tsx | 3 +-- packages/wds/src/components/modal/hooks.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/wds/src/components/date-range-calendar/index.tsx b/packages/wds/src/components/date-range-calendar/index.tsx index b19bca1fd..823d6e5f0 100644 --- a/packages/wds/src/components/date-range-calendar/index.tsx +++ b/packages/wds/src/components/date-range-calendar/index.tsx @@ -112,8 +112,7 @@ const DateRangeCalendar = forwardRef< const breakpoints = useMemo( () => Object.keys(theme.breakpoint) as Array, - // eslint-disable-next-line react-hooks/exhaustive-deps - Object.values(theme), + [theme.breakpoint], ); const calendars = diff --git a/packages/wds/src/components/modal/hooks.ts b/packages/wds/src/components/modal/hooks.ts index 12022b15c..51635ed65 100644 --- a/packages/wds/src/components/modal/hooks.ts +++ b/packages/wds/src/components/modal/hooks.ts @@ -35,8 +35,7 @@ export const useDraggable = ({ const breakpoint = useMemo( () => Object.keys(theme.breakpoint) as Array, - // eslint-disable-next-line react-hooks/exhaustive-deps - Object.values(theme), + [theme.breakpoint], ); const variant = useMedia(