Field.DateRangePicker

Field.DateRangePicker는 Field 안에서 범위 모드의 Calendar를 Popover로 여는 버튼입니다. value는 시작일과 종료일을 모두 포함하는 { start, end } 형태의 DateRange나 null입니다. 달력에서 처음 고른 날짜가 범위를 시작하고 두 번째로 고른 날짜가 끝내며, 순서는 상관없습니다. 그러면 onValueChange가 범위를 받고 달력이 닫힙니다. 달력의 min, max, isDateDisabled, firstDayOfWeek, locale을 받습니다.

이 페이지의 내용
const id = useId();
const [range, setRange] = useState<DateRange | null>(null);
const [text, setText] = useState('');
const [invalid, setInvalid] = useState(false);

<Field.Group>
  <Label.Root htmlFor={id}>Stay</Label.Root>
  <Field.Root id={id} invalid={invalid}>
    <Field.TextInput
      value={text}
      onChange={(event) => {
        setText(event.target.value);
        setRange(parseDateRange(event.target.value));
        setInvalid(false);
      }}
      onBlur={() => {
        if (range) setText(formatDateRange(range));
        else setInvalid(text.trim() !== '');
      }}
    />
    <Field.DateRangePicker
      value={range}
      onValueChange={(next) => {
        setRange(next);
        setText(formatDateRange(next));
        setInvalid(false);
      }}
      showValue={false}
    />
  </Field.Root>
</Field.Group>

여기서는 같은 필드의 Field.TextInput으로 범위를 입력할 수도 있습니다. 고른 범위는 formatDateRange로 텍스트에 쓰이고, parseDateRange가 범위로 읽은 입력은 달력을 그 시작일로 옮깁니다. Field.TextInput 옆에서는 그 입력이 필드의 컨트롤입니다. 입력이 필드의 id, name, 설명을 받고, 필드의 다른 곳을 누르면 입력에 포커스가 갑니다. showValue={false}이면 버튼은 달력 글리프만 보여 줍니다. 버튼은 자신의 name이 있으면 그 이름으로 폼에 범위를 보냅니다. 없으면 필드의 name으로 보내는데, Field.ColorPicker, Field.DatePicker, Field.DateRangePicker, Field.TimePicker, Field.DateTimePicker가 아닌 Field 파트나 useField로 연결한 컨트롤이 그 name을 가지고 있으면 보내지 않습니다. 입력한 텍스트 대신 2026-10-03/2026-10-10 형식으로 범위를 보내려면 Root 대신 선택기에 name을 두세요.

입력한 범위

formatDateRange(range, { locale })는 두 날짜를 formatDate가 쓰는 대로 쓰고, 그 사이에 로캘의 범위 구분자를 넣습니다. 예: en-US에서는 10/3/2026 – 10/10/2026, ko에서는 2026. 10. 3. ~ 2026. 10. 10.. parseDateRange(text, { locale })는 입력한 텍스트를 DateRange로 읽고, 범위가 아니면 null을 돌려줍니다. 글자나 숫자가 아닌 문자가 이어진 곳마다, 그리고 로캘의 범위 구분자에서 텍스트를 나누고, 양쪽을 parseDate로 날짜로 읽습니다. 날짜 두 개로 읽히는 나눔이 정확히 하나일 때 범위를 돌려주므로, 10/3/2026 - 10/10/2026, Oct 3, 2026 – Oct 10, 2026, 그리고 2026-10-03/2026-10-10 같은 ISO 8601 구간을 읽습니다. formatDateRange가 쓰는 대로 날짜마다 연도가 있어야 하므로, Oct 3 – Oct 10, 2026에는 null을 돌려줍니다. 종료일을 먼저 쓴 범위도 달력에서 고를 때처럼 앞선 날짜를 시작일로 읽습니다. locale이 없으면 둘 다 런타임의 로캘을 씁니다.

예시에서는 범위가 아닌 텍스트가 범위를 비우므로 폼은 범위를 보내지 않고, 필드는 포커스를 잃을 때 유효하지 않음으로 표시됩니다. parseDateRange는 선택기의 min, max, isDateDisabled도, 두 날짜 사이의 날을 모두 고를 수 있는지도 확인하지 않으므로, 입력한 범위는 직접 확인하세요.

"최근 7일" 같은 프리셋은 선택기에 포함되지 않습니다. 선택기 옆에 버튼을 두고 그 value를 정하는 Date range with presets 레시피에서 다룹니다.

단독으로

단독으로 쓰면 Field.DateRangePicker가 필드의 컨트롤입니다. 범위를 Intl.DateTimeFormat의 formatRange로 locale의 medium 스타일에 맞춰 보여 줍니다. 예: Oct 3 – 10, 2026. 하루짜리 범위는 날짜 하나로, 범위가 없으면 placeholder를 보여 줍니다. 필드의 다른 곳을 누르면 달력이 열립니다. Root에 name이 있으면 폼은 범위를 ISO 8601 구간으로 보냅니다. 예: 2026-10-03/2026-10-10.

달력은 open과 onOpenChange, 또는 defaultOpen으로 열고 닫습니다. onOpenChange는 닫힌 이유를 받습니다. 범위의 두 번째 날짜를 고르면 'select', 그 밖에는 'escape', 'outside', 'trigger', 필드가 비활성화되면 'disabled'입니다. 열리면 달력은 범위의 시작일에, 없으면 오늘이나 범위 안의 가장 가까운 날짜에 포커스를 둡니다. 첫 번째 날짜를 고른 뒤에 Escape를 누르거나 Android 뒤로 가기 제스처 같은 모달의 닫기 요청이 오면 그 선택이 취소되고 달력은 열린 채로 남습니다. 고른 날짜가 없을 때 Escape를 누르면 달력이 닫히고, 포커스는 열리기 전에 있던 곳으로 돌아갑니다. 달력이 닫히면 첫 번째로 고른 날짜는 사라집니다.

required는 범위가 없는 폼을 유효하지 않게 합니다. 브라우저는 선택기가 버튼 옆에 렌더링하는 네이티브 텍스트 입력에서 이를 검사합니다. 이 입력은 화면과 보조 기술에서 숨겨지고 탭 정지점이 아니며, 폼이 필드의 name으로 보내는 값입니다. Form.Root 안에서는 범위가 없다는 문제가 필드의 ErrorMessage.Root에 페이지의 언어로 나타나고, 포커스는 숨은 입력이 아니라 버튼으로 갑니다. 요약의 해당 필드 행도 버튼으로 연결됩니다. Form.Root 밖에서도 브라우저가 제출을 막고, 포커스는 마찬가지로 버튼에 놓입니다. 범위를 담는 네이티브 입력은 없으므로 브라우저는 min, max, isDateDisabled를 검사하지 않으며, 고른 범위는 달력이 이 안에 둡니다.

접근성을 지키는 조합

Todae가 맡는 것: 단독으로 쓰면 버튼은 Field.Root에서 id, aria-label, aria-labelledby, aria-describedby를 받고, Root나 바깥 Fieldset에서 네이티브 disabled와 aria-invalid를 받습니다. 레이블이 버튼의 이름이 되므로, 버튼에 보이는 범위는 aria-describedby에 더해집니다. 텍스트 입력처럼 Field.ColorPicker, Field.DatePicker, Field.TimePicker, Field.DateTimePicker, 다른 Field.DateRangePicker가 아닌 Field 파트나 useField로 연결한 컨트롤이 필드에 함께 있으면 그 컨트롤이 대신 id와 aria-describedby를 받고, 버튼은 disabled와 aria-invalid를 유지하며, 자체 aria-label이 없으면 StringsProvider의 dateRangePicker 문자열로 이름이 붙습니다. 버튼에는 aria-haspopup="dialog"와 aria-expanded가, 달력이 열려 있는 동안에는 aria-controls가 붙습니다. 팝오버는 dateRangePicker 문자열로 이름이 붙은 role="dialog"이고, 안의 달력은 범위 모드의 Calendar와 같은 키로 움직이고 같은 안내를 읽어 줍니다. 글리프는 GlyphsProvider의 calendar입니다. 필수 선택기의 버튼은 버튼에 aria-required를 쓸 수 없으므로, StringsProvider의 required 문자열로 설명됩니다.

직접 제공할 것: 이름. htmlFor가 Root id인 Label이나, Root의 aria-label / aria-labelledby입니다. 텍스트 입력과 그 옆의 선택기에는 같은 범위를 주고, formatDateRange로 쓰고 parseDateRange로 읽으세요.

주의할 점: 서버 렌더는 버튼 옆에 다른 컨트롤이 있는지 알 수 없으므로, 페이지가 하이드레이션될 때까지 버튼과 그 컨트롤 둘 다 필드의 id를 가지고 필드의 name으로 값을 보냅니다. value가 있으면 서버는 그 범위를 서버의 로캘로 쓰므로, 서버에서 렌더링할 때는 locale을 넘기세요.

언제 쓰나요

Field.DateRangePicker

언제: 숙박, 여행, 보고서 기간처럼 폼에서 시작일과 종료일을 고를 때.

쓰지 않을 때: 날짜 하나 → Field.DatePicker. 페이지에 펼쳐 둔 달력에서 고르는 범위 → mode="range"인 Calendar. 시작일과 별도의 마감일처럼 따로 정하는 두 날짜 → Field.DatePicker 두 개.

formatDateRange

언제: 선택기 옆 텍스트 입력처럼, 범위를 로캘에 맞는 텍스트로 쓸 때.

쓰지 않을 때: Oct 3 – 10, 2026처럼 입력용이 아니라 읽기용인 범위 → Intl.DateTimeFormat의 formatRange.

parseDateRange

언제: 사람이 입력한 범위를 읽을 때.

쓰지 않을 때: 폼이나 API에서 온 2026-10-03/2026-10-10 같은 ISO 8601 구간 → /에서 나눠 각각 Temporal.PlainDate.from으로 읽으세요.

Props

Field.DateRangePicker props
이름타입설명
value 필수DateRange | null

범위, 없으면 null.

onValueChange(value: DateRange) => void

달력에서 두 번째 날짜를 고르면 고른 범위를 받아 호출됩니다.

minTemporal.PlainDate

고를 수 있는 가장 이른 날짜.

maxTemporal.PlainDate

고를 수 있는 가장 늦은 날짜.

requiredboolean

범위가 없으면 폼이 유효하지 않습니다.

isDateDisabled(date: Temporal.PlainDate) => boolean

공휴일처럼 고를 수 없는 날짜에 true를 돌려줍니다. 범위에는 그런 날짜가 들어갈 수 없습니다.

firstDayOfWeek1 | 2 | 3 | 4 | 5 | 6 | 7

달력의 첫 열, 1(월요일)부터 7(일요일)까지. 없으면 브라우저가 알려 주는 로캘의 첫 요일, 그것도 없으면 7.

localestring

버튼의 날짜와 달력 이름의 로캘, 예: "ko". 없으면 브라우저의 로캘이므로, 서버에서 렌더링할 때는 넘기세요.

placeholderstring

showValue가 true일 때, 범위가 없는 동안 버튼에 보입니다.

showValueboolean

버튼이 글리프 옆에 범위나 placeholder를 보여 줄지 여부. 범위를 보여 주는 텍스트 입력 옆에서는 false로 두세요.

기본값: true

openboolean

달력의 제어 열림 상태. onOpenChange와 함께 전달하세요.

defaultOpenboolean

비제어일 때 처음 열림 상태.

onOpenChange(open: boolean, reason: 'escape' | 'outside' | 'trigger' | 'select' | 'disabled') => void

달력이 열리거나 닫힐 때 호출되며, 닫힌 이유를 함께 받습니다.

formatDateRange props
이름타입설명
range 필수DateRange

쓸 범위, { start, end } 형태. 끝이 더 이른 날짜여도 더 이른 날짜를 먼저 씁니다.

options.localestring

ko 같은 로캘. 지정하지 않으면 런타임의 로캘입니다.

parseDateRange props
이름타입설명
text 필수string

입력된 텍스트. 날짜마다 연도가 있어야 합니다.

options.localestring

ko 같은 로캘. 지정하지 않으면 런타임의 로캘입니다.