Seletor de data
Um seletor de intervalo de datas de dois meses construído no RangeCalendar do React Aria. Uma barra lateral de seleção rápida (Hoje, semana passada, este mês, etc.), dois meses adjacentes, e um rodapé com um intervalo de data editável e Cancelar/Aplicar - o calendário só commite um novo intervalo uma vez Aplicado é pressionado. Uma variante de data única (abaixo) compartilha o mesmo painel de mês, renderização de célula dia e chip editável.
Agendador de reuniões
A Calendly/Cal.com-style booking panel — Figma node 3881:7618. Cartão de acolhimento e resumo de reunião à esquerda, um calendário de um mês no meio (o mesmo MonthPanel de 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.
Instalação
Você pode adicionar esses seletores de datas usando nosso CLI ou manualmente. Tanto DateRangePicker quanto DatePicker importam seus cromos compartilhados (chevrons, célula do dia, painel de mês, chip de data editável, superfícies de gatilho/popover) de shared.tsx, então adicione esse arquivo primeiro.
Modo escuro pronto - este componente segue tokens semânticos BoardUI automaticamente. Configurar a mudança de tema.
O CLI copia a fonte do componente (com suas dependências internas) em seu projeto e instala quaisquer pacotes npm necessários.
npx boardui@latest add date-range-pickerUsando um pacote de UI compartilhado ou aliases de importação personalizados? Configure seus caminhos de instalação antes de executar o CLI. Exemplos de copy-paste usam as importações padrão.
Seleção rápida
As predefinições da barra lateral são calculadas a partir de @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 para commit, o mesmo que arrastar um intervalo diretamente no calendário.
// 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> );}Valor controlado e descontrolado
Passar value + onChange para uma gama controlada, ou defaultValue descontrolado. De qualquer forma, o popover mantém o seu próprio pending draft internally — onChange só dispara quando o usuário pressiona Apply; Cancel descarta o rascunho e reverte para o último valor autorizado. Os chips de início/fim no rodapé são entradas editáveis (DD/MM/YYYY) que atualiza o mesmo projeto pendente.
const [value, setValue] = useState<DateRangeValue | null>(null);
<DateRangePicker aria-label="Report period" value={value} onChange={setValue} />Desabilitado
isDisabled escurece o botão de gatilho e bloqueia a abertura do popover.
<DateRangePicker aria-label="Report period" isDisabled />Data única
DatePicker is the non-range sibling — Figma node 3879:6708. Mesmo gatilho, popover, painel de mês, renderização de célula dia, e chip de data editável como DateRangePicker (ver shared.tsx abaixo), construída sobre uma reação-aria simples Calendar em vez de 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" />;}Mesmo padrão de valor pendente-vs-comprometido como o selecionador de intervalo, apenas com um único CalendarDate em vez de um { start, end } Um par.
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> );}