日付ピッカー
React Aria の RangeCalendar に組み込まれたデュアル月の日付範囲ピッカー。 クイック セレクト サイドバー (Today、Last week、今週など)、2 つの隣接した月、および編集可能な日付範囲とキャンセル/適用のフッター - カレンダーは、一度だけ新しい範囲をコミットします。 単一日付の variant (below) は、同じ月のパネル、デイ セルのレンダリング、および編集可能なチップを共有します。
会議スケジューラ
A Calendly/Cal.com-style booking panel — Figma node 3881:7618. 左のホストカードと会議要約、中央の1ヶ月のカレンダー(同じ) MonthPanel から shared.tsx), and a timezone / hour-format / time-slot sidebar on the right. Not a popover — a disclosure: the "Schedule a meeting" trigger toggles the whole panel open/closed, with the time-slot list scrolling within a fixed height instead of growing the panel.
インストール
これらの日付ピッカーを CLI または手動で追加できます。DateRangePicker と DatePicker の両方が共有クローム (chevrons、デイセル、月間パネル、編集可能な日付チップ、トリガー/ポップアップ表面) を shared.tsx からインポートします。
ダークモード対応:この コンポーネント はBoardUIのセマンティックトークンに合わせて自動で切り替わります。 テーマスイッチの設定.
CLIがコンポーネントのソースと内部の依存ファイルをプロジェクトにコピーし、必要なnpmパッケージをインストールします。
npx boardui@latest add date-range-picker共有UIパッケージや独自のインポートエイリアスを使う場合は、 インストールパスの設定 をCLIの実行前に設定してください。コピー&ペーストの例は、標準のインポートを使います。
クイックセレクト
サイドバープリセットは、 @internationalized/date each time the popover opens (so "Today" stays today). Selecting one updates the pending range and highlights the two months to match — it still requires Apply カレンダーに直接範囲をドラッグするのと同じコミットします。
// Computed with @internationalized/date, re-derived each time the popover opens:Today, Yesterday, Last week, This month, Last month, This year, Last year, All time
// Clicking a preset sets the *pending* range (Apply still commits it):<button onClick={() => setPendingValue(preset.range)}>{preset.label}</button>date-range-picker.tsx
// components/base/date-picker/date-range-picker.tsx"use client";
import { useMemo, useState } from "react";import { Button as AriaButton, Dialog, DialogTrigger, Popover, RangeCalendar,} from "react-aria-components";import { CalendarDate, endOfMonth, endOfYear, getLocalTimeZone, isSameDay, startOfMonth, startOfYear, today,} from "@internationalized/date";import { RiCalendarLine } from "@remixicon/react";import { AnimatePresence, motion } from "motion/react";import { Button } from "@/components/base/buttons/button";import { DateChipInput, MonthPanel, formatTriggerDate, popoverClassName, triggerButtonClassName,} from "@/components/base/date-picker/shared";import { cx } from "@/utils/cx";
/** * Figma source: Board UI → "Calendar" (node 3871:5738). * * A dual-month date range picker: trigger button opens a popover with a * quick-select sidebar, two adjacent months (react-aria's * `visibleDuration={{ months: 2 }}`), and a footer with read-only date chips, * a "N days selected" pill, and Cancel/Apply. * * Two deliberate departures from the raw Figma frame: * - The prev/next chevrons in Figma appear on *both* sides of *both* month * headers (4 total), pre-rotated generic vector exports. A dual-month * calendar only has one navigation state (the two months always stay * adjacent), so only the outer two chevrons (prev on month 1, next on * month 2) are wired up; the inner two slots are kept as invisible * spacers rather than dead click targets, preserving Figma's header * spacing without fake buttons. * - The connecting range background (Figma: one hardcoded `bg-blue-100` * rectangle sized for the one example range shown) is generalized into a * per-cell layered background driven by react-aria's `isSelectionStart` / * `isSelectionEnd` state, so any range length/position renders correctly. * * Colors/radii/spacing (semantic range-selection blues, blue-500/600, * radius/2lg/xl/2xl/3xl, * shadow-xs, background/secondary/default, background/tertiary/default, * border/button/default) are Tailwind v4 defaults or existing semantic * tokens - see styles/theme.css. * * Shared chrome (chevrons, day cell, month panel, editable date chip, trigger * + popover surfaces) lives in `./shared` - `DatePicker` (single-month, * non-range) reuses the same pieces. */
export interface DateRangeValue { start: CalendarDate; end: CalendarDate;}
export interface DateRangePickerProps { value?: DateRangeValue | null; defaultValue?: DateRangeValue | null; onChange?: (value: DateRangeValue | null) => void; isDisabled?: boolean; className?: string; "aria-label"?: string;}
function daysInRange(value: DateRangeValue) { const tz = getLocalTimeZone(); const ms = value.end.toDate(tz).getTime() - value.start.toDate(tz).getTime(); return Math.round(ms / 86_400_000) + 1;}
function useQuickSelectPresets() { return useMemo(() => { const now = today(getLocalTimeZone()); const lastMonth = now.subtract({ months: 1 }); const lastYear = now.subtract({ years: 1 }); return [ { label: "Today", range: { start: now, end: now } }, { label: "Yesterday", range: { start: now.subtract({ days: 1 }), end: now.subtract({ days: 1 }) } }, { label: "Last week", range: { start: now.subtract({ days: 7 }), end: now.subtract({ days: 1 }) } }, { label: "This month", range: { start: startOfMonth(now), end: endOfMonth(now) } }, { label: "Last month", range: { start: startOfMonth(lastMonth), end: endOfMonth(lastMonth) } }, { label: "This year", range: { start: startOfYear(now), end: endOfYear(now) } }, { label: "Last year", range: { start: startOfYear(lastYear), end: endOfYear(lastYear) } }, { label: "All time", range: { start: now.subtract({ years: 10 }), end: now } }, ]; }, []);}
function isPresetActive(value: DateRangeValue | null, range: DateRangeValue) { return !!value && isSameDay(value.start, range.start) && isSameDay(value.end, range.end);}
function QuickSelect({ value, onSelect,}: { value: DateRangeValue | null; onSelect: (range: DateRangeValue) => void;}) { const presets = useQuickSelectPresets();
return ( <div className="flex w-[118px] shrink-0 flex-col gap-1.5"> {presets.map((preset) => ( <button key={preset.label} type="button" onClick={() => onSelect(preset.range)} className={cx( "w-full cursor-pointer rounded-2lg px-2 py-1.5 text-left text-body-medium text-text-primary transition-colors duration-150 ease", isPresetActive(value, preset.range) ? "bg-background-tertiary-default" : "hover:bg-background-secondary-hover", )} > {preset.label} </button> ))} </div> );}
function Footer({ value, onChange, onCancel, onApply,}: { value: DateRangeValue | null; onChange: (value: DateRangeValue) => void; onCancel: () => void; onApply: () => void;}) { return ( <div className="flex items-center justify-between pt-3 pr-4"> <div className="flex items-center gap-2.5"> <AnimatePresence> {value && ( <motion.div key="range-summary" initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }} className="flex items-center gap-2.5" > <div className="flex items-center gap-[5px]"> <DateChipInput date={value.start} label="Start date" onCommit={(start) => onChange({ start, end: start.compare(value.end) > 0 ? start : value.end }) } /> <span className="text-body-medium text-text-secondary">-</span> <DateChipInput date={value.end} label="End date" onCommit={(end) => onChange({ start: end.compare(value.start) < 0 ? end : value.start, end }) } /> </div> <span className="rounded-xl bg-background-tertiary-default px-2 py-2 text-body-medium text-text-secondary"> {daysInRange(value)} day{daysInRange(value) === 1 ? "" : "s"} selected </span> </motion.div> )} </AnimatePresence> </div> <div className="flex items-center gap-2.5"> <Button variant="secondary" onClick={onCancel}> Cancel </Button> <Button onClick={onApply} disabled={!value}> Apply </Button> </div> </div> );}
export function DateRangePicker({ value, defaultValue = null, onChange, isDisabled, className, "aria-label": ariaLabel = "Date range",}: DateRangePickerProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState<DateRangeValue | null>(defaultValue); const committedValue = isControlled ? (value ?? null) : internalValue;
const [pendingValue, setPendingValue] = useState<DateRangeValue | null>(committedValue);
const commit = (next: DateRangeValue | null) => { if (!isControlled) setInternalValue(next); onChange?.(next); };
return ( <DialogTrigger onOpenChange={(isOpen) => { if (isOpen) setPendingValue(committedValue); }} > <AriaButton isDisabled={isDisabled} className={cx(triggerButtonClassName, className)}> <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden /> <span className="flex items-center justify-center px-1 text-body-medium text-text-primary"> {committedValue ? `${formatTriggerDate(committedValue.start)} - ${formatTriggerDate(committedValue.end)}` : "Select date range"} </span> </AriaButton> <Popover offset={4} className={popoverClassName}> <Dialog aria-label={ariaLabel} className="outline-none"> {({ close }) => ( <RangeCalendar aria-label={ariaLabel} visibleDuration={{ months: 2 }} value={pendingValue} onChange={setPendingValue} > <div className="flex gap-3"> <div className="pt-4 pl-4"> <QuickSelect value={pendingValue} onSelect={(range) => setPendingValue(range)} /> </div> <div className="flex flex-col pt-2 pr-2 pb-3"> <div className="flex gap-2"> <MonthPanel offset={0} showPrev /> <MonthPanel offset={1} showNext /> </div> <Footer value={pendingValue} onChange={setPendingValue} onCancel={() => { setPendingValue(committedValue); close(); }} onApply={() => { commit(pendingValue); close(); }} /> </div> </div> </RangeCalendar> )} </Dialog> </Popover> </DialogTrigger> );}制御および制御不能な価値
パス value + onChange 制御された範囲のため、または defaultValue 制御不能のため。 いずれにしても、ポップアップはそれ自体を維持します pending draft internally — onChange ユーザーがプレスしたときにのみ消火 Apply; Cancel 草案を破棄し、最後のコミットされた値に戻します。 フッターの開始/終了チップは編集可能な入力です(DD/MM/YYYY)同じ保留草案を更新する。
const [value, setValue] = useState<DateRangeValue | null>(null);
<DateRangePicker aria-label="Report period" value={value} onChange={setValue} />無効
isDisabled トリガーボタンを分割し、ポップアップを開くブロックします。
<DateRangePicker aria-label="Report period" isDisabled />単一日付
DatePicker is the non-range sibling — Figma node 3879:6708. 同じ制動機、ポップアップ、月のパネル、日細胞のレンダリングおよび編集可能な日付の破片として DateRangePicker (参照) shared.tsx (以下) を参照)。基盤には react-aria の通常の Calendar 代わりに RangeCalendar. There's only one month, so — unlike the dual view, where the two inner chevrons are inert spacers — both nav chevrons are fully functional. The Figma frame has no quick-select sidebar.
import { DatePicker } from "@/components/base/date-picker/date-picker";
export function Example() { return <DatePicker aria-label="Meeting date" />;}同じ pending-vs-committed 値 パターン として 範囲 ピッカー, ちょうど 1 つで CalendarDate ではなく、 { start, end } ペア。
const [value, setValue] = useState<CalendarDate | null>(null);
<DatePicker aria-label="Due date" value={value} onChange={setValue} />date-picker.tsx
// components/base/date-picker/date-picker.tsx"use client";
import { useState } from "react";import { Button as AriaButton, Calendar, Dialog, DialogTrigger, Popover } from "react-aria-components";import type { CalendarDate } from "@internationalized/date";import { RiCalendarLine } from "@remixicon/react";import { AnimatePresence, motion } from "motion/react";import { Button } from "@/components/base/buttons/button";import { DateChipInput, MonthPanel, formatTriggerDate, popoverClassName, triggerButtonClassName,} from "@/components/base/date-picker/shared";import { cx } from "@/utils/cx";
/** * Figma source: Board UI → "Calendar_single" (node 3879:6708). * * A single-date picker: trigger button opens a popover with one month * (react-aria's plain `Calendar`, so both nav chevrons are fully functional - * unlike `DateRangePicker`'s dual view, there's only one month to navigate) * and a footer with an editable "DD/MM/YYYY" chip plus Cancel/Apply. No * quick-select sidebar in this Figma frame. * * Reuses the exact chevrons, day-cell rendering, month-panel chrome, editable * date chip, and trigger/popover surfaces built for `DateRangePicker` - see * `./shared`. A plain `Calendar`'s `CalendarCellRenderProps` always reports * `isSelectionStart === isSelectionEnd === isSelected` for the one chosen * day, so the shared `DayCell` renders it as a single fully-rounded pill * with no connecting range background, matching Figma without any * single-vs-range branching in the cell itself. */
export interface DatePickerProps { value?: CalendarDate | null; defaultValue?: CalendarDate | null; onChange?: (value: CalendarDate | null) => void; isDisabled?: boolean; className?: string; "aria-label"?: string;}
export function DatePicker({ value, defaultValue = null, onChange, isDisabled, className, "aria-label": ariaLabel = "Date",}: DatePickerProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState<CalendarDate | null>(defaultValue); const committedValue = isControlled ? (value ?? null) : internalValue;
const [pendingValue, setPendingValue] = useState<CalendarDate | null>(committedValue);
const commit = (next: CalendarDate | null) => { if (!isControlled) setInternalValue(next); onChange?.(next); };
return ( <DialogTrigger onOpenChange={(isOpen) => { if (isOpen) setPendingValue(committedValue); }} > <AriaButton isDisabled={isDisabled} className={cx(triggerButtonClassName, className)}> <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden /> <span className="flex items-center justify-center px-1 text-body-medium text-text-primary"> {committedValue ? formatTriggerDate(committedValue) : "Select date"} </span> </AriaButton> <Popover offset={4} className={popoverClassName}> <Dialog aria-label={ariaLabel} className="outline-none"> {({ close }) => ( <Calendar aria-label={ariaLabel} value={pendingValue} onChange={setPendingValue}> <div className="flex flex-col pt-2 pr-2 pb-3 pl-2"> <MonthPanel offset={0} showPrev showNext /> <div className="flex items-center justify-between pt-3 pr-4 pl-4"> <div> <AnimatePresence> {pendingValue && ( <motion.div key="date-summary" initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }} > <DateChipInput date={pendingValue} label="Date" onCommit={setPendingValue} /> </motion.div> )} </AnimatePresence> </div> <div className="flex items-center gap-2.5"> <Button variant="secondary" onClick={() => { setPendingValue(committedValue); close(); }} > Cancel </Button> <Button onClick={() => { commit(pendingValue); close(); }} disabled={!pendingValue} > Apply </Button> </div> </div> </div> </Calendar> )} </Dialog> </Popover> </DialogTrigger> );}