Recurrence Editor
Edit an RRULE repeat rule with a plain-language summary and the next occurrences.
@drift-ui/recurrence-editorRegistry JSONOn this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
API reference
RecurrenceEditor
Edits an RFC 5545 RRULE: interval and frequency, weekdays for weekly rules, day of the month or ordinal weekday for monthly rules, and an end that is never, a date or a count. Shows a plain-language summary and, optionally, the next occurrences.
| Prop | Type | Default |
|---|---|---|
valuestringControlled RRULE, such as "FREQ=WEEKLY;BYDAY=TU". | string | |
defaultValuestring= "FREQ=WEEKLY"Initial value when uncontrolled. | string | "FREQ=WEEKLY" |
onValueChange(rule: string) => voidCalled with the new RRULE. | (rule: string) => void | |
startDate= todayFirst occurrence. Sets the default weekday, month day and time, and where the preview starts. | Date | today |
showPreviewnumber= 0How many upcoming occurrences to list. | number | 0 |
Other props go to the <fieldset>.
parseRRule, formatRRule, describeRecurrence, occurrences
parseRRule(rule: string): Recurrence; formatRRule(recurrence: Recurrence): string; describeRecurrence(recurrence: Recurrence, start: Date): string; occurrences(recurrence: Recurrence, start: Date, limit: number): Date[]Import from @/lib/recurrence to read, write, summarize or expand a rule outside the editor.
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
describeDays,
describeRecurrence,
formatRRule,
FREQUENCIES,
isLastWeekOf,
occurrences,
ordinalWeekOf,
parseRRule,
WEEKDAY_NAMES,
WEEKDAYS,
weekdayOf,
type Frequency,
type Recurrence,
} from "@/lib/recurrence"
import { useControllableState } from "@/hooks/use-controllable-state"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
type EndMode = "never" | "until" | "count"
const UNIT_LABELS: Record<Frequency, [string, string]> = {
DAILY: ["day", "days"],
WEEKLY: ["week", "weeks"],
MONTHLY: ["month", "months"],
YEARLY: ["year", "years"],
}
const DEFAULT_COUNT = 10
const DEFAULT_UNTIL_MONTHS = 3
const SELECT_CLASS_NAME =
"h-9 rounded-md border border-input bg-transparent px-2 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30 [&>option]:bg-popover [&>option]:text-popover-foreground"
function subscribeToNothing() {
return () => {}
}
function endOfDay(year: number, month: number, day: number) {
return new Date(year, month, day, 23, 59, 59)
}
function toDateInputValue(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function capitalize(text: string) {
return text.charAt(0).toUpperCase() + text.slice(1)
}
function monthlyChoices(recurrence: Recurrence, start: Date) {
const weekday = weekdayOf(start)
const patches: Pick<Recurrence, "byDay" | "byMonthDay">[] = [
{ byDay: [], byMonthDay: [start.getDate()] },
]
if (ordinalWeekOf(start) <= 4) {
patches.push({
byDay: [{ weekday, ordinal: ordinalWeekOf(start) }],
byMonthDay: [],
})
}
if (isLastWeekOf(start)) {
patches.push({ byDay: [{ weekday, ordinal: -1 }], byMonthDay: [] })
}
const current = {
byDay: recurrence.byDay,
byMonthDay:
recurrence.byDay.length === 0 && recurrence.byMonthDay.length === 0
? [start.getDate()]
: recurrence.byMonthDay,
}
const choices = patches.map((patch) => ({
key: formatRRule({ ...recurrence, ...patch }),
label: capitalize(describeDays({ ...recurrence, ...patch }, start)),
patch,
}))
const currentKey = formatRRule({ ...recurrence, ...current })
if (!choices.some((choice) => choice.key === currentKey)) {
choices.unshift({
key: currentKey,
label: capitalize(describeDays(recurrence, start)),
patch: current,
})
}
return { choices, currentKey }
}
function RecurrenceEditor({
value,
defaultValue = "FREQ=WEEKLY",
onValueChange,
start,
showPreview = 0,
className,
...props
}: Omit<React.ComponentProps<"fieldset">, "defaultValue"> & {
value?: string
defaultValue?: string
onValueChange?: (rule: string) => void
start?: Date
showPreview?: number
}) {
const id = React.useId()
const { value: rule, setValue: setRule } = useControllableState({
value,
defaultValue,
onChange: onValueChange,
})
const [today] = React.useState(() => new Date())
const isClient = React.useSyncExternalStore(
subscribeToNothing,
() => true,
() => false
)
const anchor = start ?? today
const recurrence = parseRRule(rule)
const { frequency, interval, count, until } = recurrence
const endMode: EndMode = count ? "count" : until ? "until" : "never"
const selectedWeekdays =
recurrence.byDay.length > 0
? recurrence.byDay.map((day) => day.weekday)
: [weekdayOf(anchor)]
const monthly = monthlyChoices(recurrence, anchor)
const upcoming =
showPreview > 0 ? occurrences(recurrence, anchor, showPreview) : []
const dateFormatter = new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})
function update(patch: Partial<Recurrence>) {
setRule(formatRRule({ ...recurrence, ...patch }))
}
function changeFrequency(next: Frequency) {
update({
frequency: next,
byDay: next === "WEEKLY" ? [{ weekday: weekdayOf(anchor) }] : [],
byMonthDay: next === "MONTHLY" ? [anchor.getDate()] : [],
})
}
function toggleWeekday(weekday: (typeof WEEKDAYS)[number]) {
const isSelected = selectedWeekdays.includes(weekday)
if (isSelected && selectedWeekdays.length === 1) return
const next = isSelected
? selectedWeekdays.filter((day) => day !== weekday)
: [...selectedWeekdays, weekday]
update({
byDay: WEEKDAYS.filter((day) => next.includes(day)).map((day) => ({
weekday: day,
})),
})
}
function changeEndMode(mode: EndMode) {
update({
count: mode === "count" ? (count ?? DEFAULT_COUNT) : undefined,
until:
mode === "until"
? (until ??
endOfDay(
anchor.getFullYear(),
anchor.getMonth() + DEFAULT_UNTIL_MONTHS,
anchor.getDate()
))
: undefined,
})
}
return (
<fieldset
data-slot="recurrence-editor"
className={cn("grid min-w-0 gap-4 text-sm", className)}
{...props}
>
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor={`${id}-interval`}>Repeat every</Label>
<NumberInput
id={`${id}-interval`}
min={1}
value={interval}
onValueChange={(next) => {
if (next !== null) update({ interval: Math.round(next) })
}}
className="w-28"
/>
<select
aria-label="Frequency"
value={frequency}
onChange={(event) => changeFrequency(event.target.value as Frequency)}
className={SELECT_CLASS_NAME}
>
{FREQUENCIES.map((option) => (
<option key={option} value={option}>
{UNIT_LABELS[option][interval === 1 ? 0 : 1]}
</option>
))}
</select>
</div>
{frequency === "WEEKLY" && (
<div
role="group"
aria-labelledby={`${id}-weekdays`}
className="flex flex-wrap items-center gap-2"
>
<span id={`${id}-weekdays`} className="mr-1 font-medium">
Repeat on
</span>
{WEEKDAYS.map((weekday) => (
<button
key={weekday}
type="button"
aria-label={WEEKDAY_NAMES[weekday]}
aria-pressed={selectedWeekdays.includes(weekday)}
onClick={() => toggleWeekday(weekday)}
className="flex size-8 items-center justify-center rounded-full border border-input text-xs font-medium transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
{WEEKDAY_NAMES[weekday].charAt(0)}
</button>
))}
</div>
)}
{frequency === "MONTHLY" && (
<select
aria-label="Day of the month"
value={monthly.currentKey}
onChange={(event) => {
const choice = monthly.choices.find(
(option) => option.key === event.target.value
)
if (choice) update(choice.patch)
}}
className={cn(SELECT_CLASS_NAME, "w-fit")}
>
{monthly.choices.map((choice) => (
<option key={choice.key} value={choice.key}>
{choice.label}
</option>
))}
</select>
)}
<fieldset className="grid gap-2">
<legend className="mb-2 font-medium">Ends</legend>
<label className="flex h-9 items-center gap-2">
<input
type="radio"
name={`${id}-end`}
checked={endMode === "never"}
onChange={() => changeEndMode("never")}
className="size-4 accent-primary"
/>
Never
</label>
<div className="flex items-center gap-2">
<label className="flex w-16 items-center gap-2">
<input
type="radio"
name={`${id}-end`}
checked={endMode === "until"}
onChange={() => changeEndMode("until")}
className="size-4 accent-primary"
/>
On
</label>
<Input
type="date"
aria-label="End date"
disabled={endMode !== "until"}
value={until ? toDateInputValue(until) : ""}
min={toDateInputValue(anchor)}
onChange={(event) => {
const [year, month, day] = event.target.value
.split("-")
.map(Number)
if (year && month && day) {
update({ until: endOfDay(year, month - 1, day) })
}
}}
className="w-auto"
/>
</div>
<div className="flex items-center gap-2">
<label className="flex w-16 items-center gap-2">
<input
type="radio"
name={`${id}-end`}
checked={endMode === "count"}
onChange={() => changeEndMode("count")}
className="size-4 accent-primary"
/>
After
</label>
<NumberInput
aria-label="Occurrences"
min={1}
disabled={endMode !== "count"}
value={count ?? DEFAULT_COUNT}
onValueChange={(next) => {
if (next !== null) update({ count: Math.round(next) })
}}
className="w-28"
/>
<span className="text-muted-foreground">
{count === 1 ? "occurrence" : "occurrences"}
</span>
</div>
</fieldset>
<p
data-slot="recurrence-editor-summary"
aria-live="polite"
className="font-medium"
>
{describeRecurrence(recurrence, anchor)}
</p>
{upcoming.length > 0 && (
<div className="grid gap-1.5">
<span id={`${id}-preview`} className="text-muted-foreground">
Next occurrences
</span>
<ol
aria-labelledby={`${id}-preview`}
data-slot="recurrence-editor-preview"
className="grid gap-1 tabular-nums"
>
{upcoming.map((date) => (
<li key={date.getTime()} className="min-h-5">
{isClient && dateFormatter.format(date)}
</li>
))}
</ol>
</div>
)}
</fieldset>
)
}
export { RecurrenceEditor }