Date range with presets
패키지 네임스페이스가 아니라 레시피입니다. "Last 7 days"처럼 자주 고르는 범위는 Field.DateRangePicker 옆에 둔 단일 선택 ToggleGroup이고, 하나를 누르면 선택기의 value가 정해집니다. 선택기는 그 밖의 범위도 그대로 받습니다. DateRangePresets.* export는 없습니다.
이 페이지의 내용
프리셋을 누른 다음, 달력에서 직접 범위를 골라 보세요.
프리셋
프리셋마다 오늘을 받아 DateRange를 돌려주는 함수이므로, 그 날짜는 누른 날을 따라갑니다. 오늘은 페이지를 불러올 때 한 번이 아니라, 프리셋을 누르거나 토글이 렌더링될 때 Temporal.Now.plainDateISO()로 읽으세요. 여기서 "Last 7 days"와 "Last 30 days"는 오늘에서 끝나고, "This month"는 그 달의 첫날부터 오늘까지입니다.
import type { DateRange } from '@tounsoo/todae';
const PRESETS: { id: string; label: string; range: (today: Temporal.PlainDate) => DateRange }[] = [
{ id: 'last-7', label: 'Last 7 days', range: (today) => ({ start: today.subtract({ days: 6 }), end: today }) },
{ id: 'last-30', label: 'Last 30 days', range: (today) => ({ start: today.subtract({ days: 29 }), end: today }) },
{ id: 'this-month', label: 'This month', range: (today) => ({ start: today.with({ day: 1 }), end: today }) },
];
function isRangeOf(preset: (typeof PRESETS)[number], range: DateRange, today: Temporal.PlainDate) {
const { start, end } = preset.range(today);
return start.equals(range.start) && end.equals(range.end);
}
// The preset whose range is the value today, or null for a custom range. Two presets can give the
// same range on some days, such as "This month" and "Last 7 days" on the 7th, so the last one
// pressed wins while the value is still its range.
function presetOf(range: DateRange | null, pressed: string | null) {
if (!range) return null;
const today = Temporal.Now.plainDateISO();
const last = PRESETS.find((each) => each.id === pressed);
if (last && isRangeOf(last, range, today)) return last.id;
return PRESETS.find((each) => isRangeOf(each, range, today))?.id ?? null;
}
필드
범위가 기준입니다. 토글의 value는 범위에서 계산하므로, 달력에서 하루씩 고른 범위를 포함해 값이 어떤 프리셋의 범위와 같으면 그 프리셋을 보여 주고, 그 밖의 범위에서는 아무것도 보여 주지 않습니다. 마지막으로 누른 프리셋의 id는 범위가 같은 프리셋이 여럿일 때 가르는 용도로만 범위 옆에 두며, 범위가 아직 그 프리셋의 것일 때만 쓰입니다. 눌린 프리셋을 다시 누르면 null이 오는데, 핸들러가 이를 무시하므로 범위는 그대로입니다.
import { Field, Label, Stack, ToggleGroup, type DateRange } from '@tounsoo/todae';
import { useId, useState } from 'react';
// Your app's hook for the page's language; the docs site's own is useLocale.
import { useLocale } from './useLocale';
// PRESETS and presetOf are the ones above.
export function DateRangePresets() {
const id = useId();
const locale = useLocale();
const [range, setRange] = useState<DateRange | null>(null);
const [pressed, setPressed] = useState<string | null>(null);
return (
<Stack.Root gap="condensed">
<Field.Group>
<Label.Root htmlFor={id}>Report dates</Label.Root>
<Field.Root id={id}>
<Field.DateRangePicker value={range} onValueChange={setRange} locale={locale} placeholder="Pick dates" />
</Field.Root>
</Field.Group>
<ToggleGroup.Root
aria-label="Preset ranges"
value={presetOf(range, pressed)}
onValueChange={(next) => {
// Pressing the pressed preset again gives null, which keeps its range.
const preset = PRESETS.find((each) => each.id === next);
if (!preset) return;
setRange(preset.range(Temporal.Now.plainDateISO()));
setPressed(preset.id);
}}
>
{PRESETS.map((preset) => (
<ToggleGroup.Item key={preset.id} value={preset.id}>
{preset.label}
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
</Stack.Root>
);
}
Field.Root에 name을 주면 폼은 선택기가 다른 범위를 보낼 때처럼 범위를 2026-03-06/2026-03-12 같은 ISO 8601 구간으로 보내므로, 프리셋 때문에 서버가 읽는 값이 달라지지 않습니다.
접근성을 지키는 조합
Todae가 맡는 것: ToggleGroup.Root는 Tab 정지점이 하나이고 화살표 키로 움직이는 role="group"이며, 항목마다 그룹의 값에 따라 aria-pressed가 붙습니다. Field.DateRangePicker는 레이블로 이름이 붙고 보여 주는 범위로 설명되므로, 프리셋을 누르면 그 설명도 따라 바뀝니다. 선택기의 달력, 값 입력, required 검사는 선택기 페이지에서와 같이 동작합니다.
직접 제공할 것: 토글 그룹의 이름과, 각 범위를 나타내는 레이블. 선택기의 min, max, isDateDisabled를 지키는 프리셋. 프리셋은 value를 직접 정하므로 달력의 검사를 거치지 않습니다.
주의할 점: 페이지를 불러올 때 오늘을 한 번만 읽는 것. 자정이 지나도록 열어 둔 페이지에서는 프리셋이 어제의 범위를 정하고 눌린 것으로 보입니다. 토글의 값을 마지막으로 누른 프리셋만으로 정하는 것. 누군가 달력에서 고르거나 범위를 입력하면 범위와 어긋나므로, 마지막으로 누른 프리셋은 범위가 아직 그 프리셋과 같을 때 겹친 프리셋 중 하나를 고르는 데만 씁니다. 범위가 같은 첫 프리셋을 보여 주는 것. 7일에는 "This month"가 "Last 7 days"와 같아서, 하나를 눌러도 다른 하나가 눌린 것으로 보입니다.
언제 쓰나요
쓸 때: 보고서의 기간이나 로그 검색처럼, 오늘을 기준으로 같은 기간을 자주 고르는 범위.
쓰지 않을 때: 날짜 하나 → Field.DatePicker. 숙박 날짜처럼 자주 쓰는 기간이 없는 범위 → Field.DateRangePicker 하나만.