منتقي التاريخ

منتقي نطاق تاريخ بشهرين مبني على RangeCalendar من React Aria. شريط جانبي للاختيار السريع (اليوم، الأسبوع الماضي، هذا الشهر، إلخ)، وشهران متجاوران، وتذييل مع نطاق تاريخ قابل للتحرير وإلغاء/تطبيق - لا يُثبّت التقويم نطاقاً جديداً إلا عند الضغط على تطبيق. يشترك متغير التاريخ الواحد (أدناه) في نفس لوحة الشهر وعرض خلية اليوم والشريحة القابلة للتحرير.

جدولة الاجتماعات

A Calendly/Cal.com-style booking panel — Figma node 3881:7618. بطاقة المضيف وملخص الاجتماع على اليسار، وتقويم لشهر واحد في المنتصف (نفس 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 واجهتهما المشتركة (الأسهم، خلية اليوم، لوحة الشهر، شريحة التاريخ القابلة للتعديل، أسطح التشغيل/النافذة المنبثقة) من shared.tsx، لذا أضف هذا الملف أولاً.

الوضع الداكن جاهز - هذا مكوّن يتبع رموز التصميم الدلالية لـ BoardUI تلقائياً. إعداد تبديل السمة.

تنسخ CLI مصدر المكوّن (مع تبعياته الداخلية) إلى مشروعك وتثبّت أي حزم npm مطلوبة.

npx boardui@latest add date-range-picker

هل تستخدم حزمة واجهة مستخدم مشتركة أو أسماء استيراد مختصرة مخصصة؟ تهيئة مسارات التثبيت قبل تشغيل 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 أدناه)، مبني على 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" />;}

نفس نمط القيمة المعلّقة مقابل المُثبَّتة كما في منتقي النطاق، لكن مع 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> );}