Recurrence Picker
The body of a repeat popover — presets, a custom editor (every n days, weeks, months or years; weekdays; month day; ends) and the host's next dates.
- Status
- Since
0.39.0- Accessibility pattern
- radio presets, named weekday toggles, polite preview
Last updated
- Due
- Repeat
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Recurrence Picker before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify recurrence-pickerInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/recurrence-pickerRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/date-time, @vegastack/badge, @vegastack/date-picker, @vegastack/label, @vegastack/number-field, @vegastack/radio-group, @vegastack/select, @vegastack/separator, @vegastack/skeleton, @vegastack/time-field, @vegastack/toggle-group.
Usage
import {
RecurrencePicker,
type RecurrencePickerView,
type RecurrenceValue,
} from "@/components/ui/recurrence-picker";
const [draft, setDraft] = useState<RecurrenceValue | null>(rule);
const [view, setView] = useState<RecurrencePickerView>("presets");
const next = useNextDates(draft); // the host's server call, debounced
<PopoverContent size={view === "custom" ? "lg" : "default"}>
<PopoverHeader>
<PopoverTitle>
{view === "custom" ? "Custom repeat" : "Repeat"}
</PopoverTitle>
</PopoverHeader>
<RecurrencePicker
value={draft}
onChange={setDraft}
noneLabel="Doesn't repeat"
presets={presets} // built from the record's own date: "Every week on Monday"
view={view}
onViewChange={setView}
preview={next.dates}
previewLoading={next.loading}
previewError={next.error ? <RetryLine onRetry={next.retry} /> : undefined}
timeZoneLabel={<TimeZoneLine />}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={cancel}>
Cancel
</Button>
<Button size="sm" onClick={() => save(draft)}>
{primaryLabel(draft, view)} {/* "Repeat weekly", "Save repeat" */}
</Button>
</div>
</PopoverContent>;The picker is the popover's body: the host owns the popover (or ResponsiveDialog), the title and
the footer buttons, and names the primary button after the choice ("Repeat weekly", "Save
repeat"). The value is plain data, RecurrenceValue, and null means it doesn't repeat. The picker
never computes a date: the host maps the value to its own rule format, formats the next dates and
passes them back as preview.
A preset is selected while value equals its rule (weekday order aside); a rule no preset matches
selects Custom…. Choosing Custom… calls onViewChange("custom") so the host can grow its
popover to lg, starts a weekly rule when there was none, and moves focus to the interval; a host
that offers a way back sets view to "presets" itself. The presets also tell the custom editor
the record's weekday and day (the weekly preset with one day, the first monthly preset), so
switching the unit to months starts on "day 10" rather than the 1st. With no such preset a new rule
starts on the week's first day and the 1st.
The picker remembers the last end date, count and month day it saw, so a choice can come back. Give
it a key per record if it stays mounted while the record changes.
Scope
One picker for anything that repeats: a task's Repeat property, a schedule (showTime), a reminder.
Fields that belong to the host's model ("Create each task", "Create the next one") go in extra,
inside the custom editor. Express "every weekday" as a weekly rule on Monday to Friday, which is
what the custom editor shows. Picking one date is the DatePicker; a time of day alone is the
TimeField.
Examples
- Due
- Repeat
Custom, with the app's fields
The custom editor at the popover's lg width. The weekdays are a multiple-choice toggle group named
in full; extra holds the host's fields between Ends and the preview.
Custom repeat
Monthly
On a day, on the nth weekday, or on the last day. A day of 29–31 says that months without it use their last day. Editing a choice's field selects that choice.
Custom repeat
Months without a 31st use their last day
States
The next dates loading (three chip skeletons, shown after a short delay so a fast answer never flashes), the host's error line with its retry (saving is still allowed), and disabled.
Loading
Error
Disabled
With a time
showTime adds a TimeField for value.time. A preset keeps the time already set, and presets
match whatever the time.
Run on a schedule
In a responsive dialog
Under 640px the host's popover becomes a bottom sheet: the same body inside ResponsiveDialog.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onChange* | (value: RecurrenceValue | null) => void | — | Fires with the new rule, or null when "doesn't repeat" is chosen. |
value* | RecurrenceValue | null | — | The rule; null when it doesn't repeat. The picker remembers the last end date, count and
month day it saw so a choice can come back: give it a key per record when it stays mounted
across records. |
className | string | — | Classes for the root. |
disabled | boolean | false | Disable every control. |
extra | React.ReactNode | — | The host's own fields, shown in the custom editor between Ends and the preview. |
hourCycle | 12 | 24 | — | The time field's clock: 12 or 24. Omit it and the locale decides. |
labels | Partial<RecurrenceLabels> | — | Replace any of the strings (RecurrenceLabels). |
locale | string | "en-US" | BCP-47 locale for the end date and the time. |
noneLabel | React.ReactNode | — | The first row, which sets null ("Doesn't repeat"). Left out, there is no such row. |
onViewChange | ((view: RecurrencePickerView) => void) | — | Fires when the picker asks for the other body. |
presets | readonly RecurrencePreset[] | — | The preset rows, in order, followed by "Custom…". A row is selected while value equals its
rule (weekday order aside; time counts unless showTime is on); a rule no preset matches
selects "Custom…". Leave out both presets and noneLabel for the custom editor alone. The
presets also seed the custom editor when its unit changes: the weekly preset with one day
gives the weekday, the first monthly one the day. With neither, it starts on the week's first
day and on the 1st. |
preview | readonly string[] | — | The next dates, formatted by the host ("Mon 10 Nov"), shown as chips after "Next:", in a live region that is there from the first paint. A new set cross-fades in. |
previewError | React.ReactNode | — | The next dates couldn't load: this line (with the host's "Try again") in place of the dates. It is announced politely, like the dates; saving the rule is still allowed. |
previewLoading | boolean | false | The next dates are loading: three chip skeletons, after a short delay. |
showTime | boolean | false | Show a time field for value.time (both bodies, while there is a rule). A preset keeps the
time already set, and presets match whatever the time. |
timeStep | number | 15 | Minutes the time field steps by. |
timeZoneLabel | React.ReactNode | — | A line under the preview: "Times in Asia/Kolkata · Change". |
view | RecurrencePickerView | — | The body shown (controlled). Choosing "Custom…" asks for "custom", so the host can grow its
popover; the picker never asks to go back, so a host offering that sets "presets" itself.
Without presets it is always the custom editor. |
weekStartsOn | 0 | 1 | 1 | The first day of the week: 1 Monday, 0 Sunday. |
Data attributes and CSS variables on RecurrencePicker
| Attribute | Values |
|---|---|
data-slot | "recurrence-picker" | "recurrence-picker-custom" | "recurrence-picker-ends" | "recurrence-picker-extra" | "recurrence-picker-month" | "recurrence-picker-month-hint" | "recurrence-picker-presets" | "recurrence-picker-preview" | "recurrence-picker-preview-error" | "recurrence-picker-preview-loading" | "recurrence-picker-time-zone" | "recurrence-picker-unit" | "recurrence-picker-weekdays" |
data-view | mirrors a prop or state value |
RecurrenceValue
| Prop | Type | Default | Description |
|---|---|---|---|
ends* | RecurrenceEnds | — | When it stops. |
freq* | RecurrenceFrequency | — | How often: every interval days, weeks, months or years. |
interval* | number | — | Every how many units (1 or more). |
month | RecurrenceMonthRule | — | Monthly: the day of the month it falls on. |
time | string | — | The time of day, "HH:mm" (24-hour), edited when showTime is on. |
weekdays | Weekday[] | — | Weekly: the days it falls on. |
RecurrencePreset
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | A stable id. |
label* | React.ReactNode | — | The row's text. |
value* | RecurrenceValue | — | The rule it stands for; the row is selected while value equals it. |
Accessibility
- The presets are a radio group: focus lands on the selected preset when the popover opens, and the arrow keys choose. Arrowing onto Custom…, or onto an end date with no day yet, only moves focus, so browsing changes nothing; Space or a click opens it. The host's popover is the dialog, named by its title.
- The weekdays are a toggle group whose buttons carry the full day name ("Monday"); the monthly and Ends choices are radio groups named by their row label, and each choice's field is labelled.
- The next dates are a polite live region (
role="status"): a new set is read once it arrives, and the region is busy while it loads. A new set cross-fades with the old one over 150ms, which reduced motion turns off.
| Key | Action |
|---|---|
| ↑ / ↓ on a choice | Move to the previous or next choice, and select it. |
| ← / → on a choice | The same, and between the weekdays. |
| ↑ / ↓ in a number field | Step the number. The arrow keys stay in a field. |
| Tab | Move between the groups and fields. |
| Space | Toggle a weekday; open Custom… or the end date. |
| Enter / Space on a menu | Open the unit, week or weekday menu. |
| Esc | Close an open menu or calendar, then the host's popup. |
| Contract | States tested |
|---|---|
| Behaviour | presets, custom, no-repeat, preset-selected, custom-selected, weekly, monthly-day, monthly-weekday, monthly-last-day, yearly, ends-never, ends-on, ends-after, with-time, with-extra, preview, preview-loading, preview-error, disabled |
| Accessibility | keyboard, labeled, radio-group, live-region, focus-management, browser-accessibility-test |
| Visual | default, custom, loading, error, disabled, dark |