khorshid

Khorshid — one install for the Jalali & Gregorian date picker: headless core, vanilla UI, React and Vue bindings.
356
Install
npm install khorshid
Latest Version:0.1.0
Vue:3
License:MIT
Last Updated:Oct 10, 2026
Links: npm
Maintainer: arshiap20

Khorshid · خورشید

Headless, zero-dependency date picker with first-class Jalali (Persian) and Gregorian calendars.

Khorshid (Persian for sun — fitting for the solar Jalali calendar) separates date-picking logic from rendering. The engine — calendars, locales, selection state, formatting, input masking — lives in a headless core with zero runtime dependencies and zero DOM usage. A polished vanilla-JS UI is built on top of it, and framework adapters are thin layers over the same core.

One install, one API. npm i khorshid gives you the vanilla picker, the React bindings and the Vue bindings — the same option names, the same events and the same values in all three:

Import What it is
khorshid The vanilla picker (createDatePicker) plus the whole headless core. No framework code.
khorshid/react React bindings: useDatePicker, <Calendar>, <DatePicker>.
khorshid/vue Vue 3 bindings: useDatePicker, <Calendar>, <DatePicker>.
khorshid/headless The DOM-free core on its own — bring your own UI.
khorshid/holidays The official holidays plugin and its providers (online or offline).
khorshid/element <khorshid-datepicker> — the picker as a framework-free Web Component.
khorshid/styles.css The one stylesheet every layer renders against.

The scoped packages (@khorshid-js/core, @khorshid-js/dom, @khorshid-js/react, @khorshid-js/vue, @khorshid-js/holidays, @khorshid-js/element) remain published and are what khorshid re-exports — use them directly if you prefer picking pieces.

Features

  • Three calendar systems — Jalali (jalaali-js break-table algorithm, matching Iran's official calendar; years −61–3177 AP), Gregorian (the same span: 560-03-20 – 3799-03-19 CE) and lunar Hijri (Umm al-Qura, 1300–1600 AH; a tabular civil variant covers 1–3000 AH). Every valid date round-trips, and all three convert losslessly through Julian Day Numbers.
  • Hijri calendar — the lunar Islamic calendar (Umm al-Qura 1300–1600 AH by default, or the tabular civil variant), with per-month adjustments to match a locally observed calendar. Every DateValue carries hijri parts.
  • Nine built-in locales — Persian, English, Dari (fa-AF: حمل، ثور …) and Pashto (ps-AF), across Jalali / Gregorian / Hijri — including the unusual-but-useful en-US: the Jalali calendar with English transliterated month names (Farvardin, Ordibehesht, …). Custom locales via registerLocale.
  • Shortcuts — "Today", "Last 7 days", "This month" … computed in the active calendar (a Jalali "this month" is Farvardin, not March), plus your own.
  • Week, month and year pickers — unit: 'week' | 'month' | 'year', combinable with range and multiple.
  • Range limits — minDays / maxDays (both ends counted), with out-of-limit ends disabled while picking.
  • Disabled dates with a reason — { date, reason } or a predicate returning a string: tooltip, accessible name, error.reason.
  • Secondary calendar — the Gregorian day under each Jalali day (or any pairing), with the spanned months under the title.
  • Natural input — فردا, ۳ روز بعد, جمعه بعد, next friday, +2w, 15 March, and A تا B ranges.
  • Native forms — name / required / formValue: ISO values submitted with the <form>, native validation, reset support.
  • Web Component — <khorshid-datepicker> with attributes, properties and sdp-* events.
  • Offline Iran holidays — holidaysPlugin({ offline: true }), no network.
  • Headless architecture — createPicker() returns plain-JS state, typed events, and precomputed render models (getGrid() → DayCell[][]). Any UI — vanilla, React, Vue — just maps cells to elements.
  • Single, range, and multiple selection with hover preview for ranges.
  • Smart input mask — auto-inserts separators, clamps months/days as you type, understands Persian (۰–۹), Arabic-Indic (٠–٩), and Latin digits, preserves the caret.
  • Responsive overlay — anchored popup that flips and shifts to stay in the viewport on desktop; a drag-to-dismiss bottom sheet on small screens.
  • Light / dark / auto themes and full CSS-variable theming — restyle everything without touching a class.
  • Tasteful animations — month slide, view zoom, selection pop; always disabled under prefers-reduced-motion.
  • Keyboard navigation & a11y — roving tabindex, ARIA grid semantics, focus trap in the sheet, localized labels.
  • RTL first-class — direction comes from the locale; arrow keys and range edges follow it.
  • Multi-month panes, min/max bounds, disabled dates (list or predicate), TypeScript strict, tree-shakeable (the only declared side effect is the stylesheet import).

Feature tour

import { createDatePicker } from 'khorshid';

// Shortcuts + range limits — "This month" is a Jalali month on a Jalali picker.
createDatePicker('#stay', {
  locale: 'fa-IR',
  mode: 'range',
  months: 2,
  shortcuts: true,          // or ['today', 'last7Days', { id, label, value }]
  minDays: 3,               // two nights, both ends counted
  maxDays: 15,
});

// Week / month / year pickers — the value is the period start + DateValue.end.
createDatePicker('#month', { locale: 'fa-IR', unit: 'month' }); // 'خرداد ۱۴۰۵'

// The Gregorian date under every Jalali day.
createDatePicker('#date', { locale: 'fa-IR', secondaryCalendar: true });

// Free text: فردا · ۳ روز بعد · جمعه بعد · next friday · +2w · "امروز تا جمعه".
createDatePicker('#due', { locale: 'fa-IR', naturalInput: true });

// Disabled days that say why (tooltip, aria-label, error.reason).
createDatePicker('#visit', {
  disabledDates: [{ date: '2026-04-10', reason: 'ظرفیت تکمیل است' }, '2026-04-11'],
});
createDatePicker('#delivery', {
  disabledDates: (d) => (d.weekday === 5 ? 'جمعه‌ها تعطیل است' : false),
});

// Native forms — submits checkin=2026-04-04 (ISO), blocks submit while empty.
createDatePicker('#checkin', { locale: 'fa-IR', name: 'checkin', required: true });

Hijri, Dari and Pashto:

import { createHijriCalendar, registerCalendar } from 'khorshid';

createDatePicker('#h', { locale: 'fa-IR-hijri' });    // ۱۴۴۷/۱۰/۰۱
createDatePicker('#af', { locale: 'fa-AF' });         // ۱ حمل ۱۴۰۵
registerCalendar(createHijriCalendar({ adjustments: { '1447-09': 29 } })); // match local moon sighting

The Web Component, and offline Iranian holidays:

<script type="module">
  import '@khorshid-js/element/define';
</script>
<form>
  <khorshid-datepicker name="stay" mode="range" min-days="2" shortcuts required></khorshid-datepicker>
</form>
import { holidaysPlugin } from 'khorshid/holidays';
createDatePicker('#d', { plugins: [holidaysPlugin({ offline: true })] }); // no network

parseNaturalDate(text, { locale, today }), toFormValue(value), core.getShortcuts() / applyShortcut(id) / submitInput(text) / getFormValue() expose the same machinery headlessly. Full guides live in the docs site.

One API, three layers

The vanilla picker, the React bindings and the Vue bindings are not three similar APIs — they are one API with three renderers:

  • The same options. UIOptions in @khorshid-js/core is the single list, with a single default table (UI_DEFAULTS). display, readOnly, outsideDays, dayProps, renderFooter, icons, classes, vars, … mean the same thing and default the same way in every layer.
  • The same events. One on<Event> callback per core event, generated from the core's event map — nothing is hand-listed per layer.
  • The same values. A literal mode narrows the value everywhere, and any value can be handed straight back as input.
  • The same rendering. All three render the same sdp* element tree from the same view model (buildPaneModels, dayClassName, rootClassName) against the same stylesheet, and the React/Vue field components use the very overlay shells the vanilla picker uses — popup positioning, drag-to-dismiss sheet, modal, focus trap, animations. React and Vue are not a reduced version.
const shared = { locale: 'fa-IR', mode: 'range', months: 2, display: 'sheet' };

createDatePicker('#field', shared);   // vanilla
<DatePicker {...shared} />            // React
<DatePicker v-bind="shared" />        // Vue

Installation

npm install khorshid

Quick start — vanilla

import { createDatePicker } from 'khorshid';
import 'khorshid/styles.css';

const picker = createDatePicker('#birthday', { locale: 'fa-IR' });

picker.on('change', ({ value }) => {
  if (value.mode === 'single' && value.date) {
    console.log(value.date.iso);       // '2026-03-21'
    console.log(value.date.formatted); // '۱۴۰۵/۰۱/۰۱'
  }
});

Bind to an <input> (or a selector for one) and you get a masked input with a popup — or a bottom sheet on small screens. Pass any other element to mount an always-visible inline calendar:

createDatePicker(document.querySelector('#calendar'), {
  mode: 'range',
  months: 2,
  locale: 'en-US-gregorian',
});

The returned DatePicker exposes open(), close(), toggle(), getValue(), setValue(), clear(), on() / off(), setTheme(), setLocale(), setCalendar(), setMode(), setVars(), destroy() — plus picker.core, the underlying headless instance, and picker.el for the rendered elements.

Where navigation lives — high-level lifecycle (open/getValue/on/…) is on the DatePicker facade; imperative calendar navigation (next, prev, goTo, setView, selectMonth, …) lives on picker.core. The facade stays small on purpose; picker.core is never a dead end.

mode vs display — mode is the selection kind ('single' | 'range' | 'multiple'); display is the overlay kind for input pickers ('auto' | 'popup' | 'sheet' | 'modal'). They're independent.

Typed values

getValue() returns a mode-tagged union. Pass a literal mode and the return type is narrowed for you — no value.mode === '…' guard needed:

const range = createDatePicker('#dates', { mode: 'range', months: 2 });
range.getValue().start;      // ✅ typed { start, end }

const many = createDatePicker('#days', { mode: 'multiple' });
many.getValue().dates;       // ✅ typed DateValue[]

The same narrowing works in React and Vue (useDatePicker({ mode: 'range' }).value.start). For a mode only known at runtime, narrow with the exported guards:

import { isRangeValue } from 'khorshid';
const v = picker.getValue();
if (isRangeValue(v)) useRange(v.start, v.end);

A value round-trips: whatever you get out of getValue() (or an onChange payload) can go straight back into setValue() / value= with no conversion.

Quick start — React

import { DatePicker, Calendar, useDatePicker } from 'khorshid/react';
import 'khorshid/styles.css';

// A field that opens a popup on desktop and a bottom sheet on mobile.
<DatePicker locale="fa-IR" onChange={({ value }) => console.log(value.date?.iso)} />

// An always-visible panel, two months, range mode.
<Calendar locale="en-US-gregorian" mode="range" months={2} />

// Or headless: own the core, render what you like.
const { value, actions, state } = useDatePicker({ mode: 'range' });
value.start; // ✅ narrowed

Quick start — Vue

<script setup lang="ts">
import { DatePicker } from 'khorshid/vue';
import 'khorshid/styles.css';
import { ref } from 'vue';

const date = ref('2026-03-21');
</script>

<template>
  <DatePicker v-model="date" locale="fa-IR" @range-complete="onDone" />
</template>

Quick start — headless (khorshid/headless)

createPicker(options) returns a PickerCore: state + subscribe (a re-render signal) + typed events + derived render models. A UI is just a render loop:

import { createPicker } from 'khorshid/headless';

const picker = createPicker({ locale: 'fa-IR', mode: 'single' });

function render() {
  const grid = picker.getGrid(); // CalendarGrid for pane 0

  header.textContent = grid.title.label;             // e.g. 'فروردین ۱۴۰۵'
  renderWeekdayRow(grid.weekdays);                   // { min, short, long, weekend }

  for (const week of grid.weeks) {                   // DayCell[][] — rows of 7
    for (const cell of week) {
      // cell.label is already in locale digits; all flags precomputed:
      // today, selected, rangeStart, rangeEnd, inRange, preview,
      // disabled, outside, weekend, focused
      const el = renderDay(cell);
      el.onclick = () => picker.select(cell.date);
      el.onmouseenter = () => picker.hover(cell.date);
    }
  }
}

const unsubscribe = picker.subscribe(render); // called after every state change
render();

prevButton.onclick = () => picker.prev();
nextButton.onclick = () => picker.next();

picker.on('change', ({ value, source }) => console.log(value, source));

This is exactly how @khorshid-js/dom, @khorshid-js/react and @khorshid-js/vue are built. In React, the useDatePicker hook wraps it for you:

import { useDatePicker } from 'khorshid/react';

const { core, state, value } = useDatePicker({ mode: 'range' });
// map core.getGrid() to components; call core.select(cell.date) on click

(Rolling your own? Don't hand core.getState straight to useSyncExternalStore: it returns a fresh snapshot per call, which re-renders forever. Cache the snapshot and refresh it in the subscribe callback — usePickerState does exactly that.)

Other useful PickerCore surface: getMonthCells() / getYearCells() / getYearRangeLabel() for the month/year views, setView(), selectMonth() / selectYear(), goTo() / goToToday(), keyboard helpers (focusDate, moveFocus, focusWeekEdge, selectFocused), format() / parse() / handleInput() for text I/O, and toDateValue() to expand any date into the full payload shape.

Built-in locales

Key Language Calendar Direction Week starts
fa-IR Persian Jalali RTL Saturday
en-US English Jalali LTR Saturday
en-US-gregorian English Gregorian LTR Sunday
fa-IR-gregorian Persian Gregorian RTL Saturday
fa-IR-hijri Persian Hijri RTL Saturday
en-US-hijri English Hijri LTR Sunday
fa-AF Dari Jalali (حمل، ثور …) RTL Saturday
fa-AF-gregorian Dari Gregorian (جنوری، فبروری …) RTL Saturday
ps-AF Pashto Jalali (وری، غویی …) RTL Saturday

A locale binds language + calendar + conventions (direction, week start, weekend, digits, month/weekday names, formats, UI labels). Calendar variants are separate locales so month names always match the calendar — and setCalendar('gregorian') on a fa-IR picker automatically switches to fa-IR-gregorian (same language, other calendar), preferring the same region (fa-AF → fa-AF-gregorian). If no variant exists for the current language, an error event with code no-locale-variant fires instead.

Add your own with registerLocale(locale); see the Locale interface in @khorshid-js/core.

Options

PickerOptions (core — accepted everywhere)

Option Type Default Description
locale string | Locale 'fa-IR' Locale key or object. Determines the calendar.
mode 'single' | 'range' | 'multiple' 'single' Selection mode.
value PickerValueInput — Initial value: a date, an array, or { start, end }.
min DateInput — Earliest selectable date.
max DateInput — Latest selectable date.
disabledDates (DateInput | { date, reason? })[] | (date: PlainDate) => boolean | string — Disable specific dates — a list, or a predicate. A reason becomes the tooltip, accessible name and error.reason.
unit 'day' | 'week' | 'month' | 'year' 'day' What one pick selects (week / month / year pickers).
minDays / maxDays number — Range length limits in days, both ends counted (two nights = 3).
shortcuts boolean | (ShortcutPreset | Shortcut)[] — Quick-pick chips, computed in the active calendar.
secondaryCalendar boolean | CalendarId — Show a second calendar's day under each day. true = natural partner.
naturalInput boolean false Accept free text (فردا, next friday, +3), committed on Enter/blur.
weekStart 0–6 locale's Override the locale's first day of week (0 = Sunday … 6 = Saturday).
months number 1 Month panes rendered side by side.
fixedWeeks boolean true Always render 6 week rows so panel height is stable.
format FormatPattern locale.formats.display Display format for values and the input.
autoClose boolean true Close after a completed selection (single: on select; range: on range end).
time boolean | TimeOptions — Enable a time-of-day picker. true = 24-hour HH:mm; an object sets minuteStep, secondStep, showSeconds, use12Hour, defaultTime.

DateInput is anything date-like: a PlainDate, { year, month, day, calendar? } parts, a native Date, or a Gregorian ISO string 'YYYY-MM-DD'.

Time picker — when time is set, the selected DateValue carries a structured time: { hour, minute, second }, formatted/getInputValue() append it (… HH:mm), and the panel shows a stepper strip below the calendar. Read or drive it imperatively via core.getTime() / core.setTime({ hour, minute }); a time:change event fires on every change (plus change with source: 'time' when a date is selected).

createDatePicker('#dt', { time: { minuteStep: 5, use12Hour: true } });
picker.on('change', ({ value }) => {
  if (value.mode === 'single' && value.date?.time) {
    const { hour, minute } = value.date.time; // 14, 30
  }
});

UI options — the same in every layer

These are UIOptions in the core contract, and they are accepted by the same names, with the same defaults, everywhere: as createDatePicker options, as React props, and as Vue props. <Calendar> (a bare panel) takes the panel half of the list; anything that can open — createDatePicker, <DatePicker> — takes all of it.

Option Type Default Description
theme 'light' | 'dark' | 'auto' 'auto' 'auto' follows prefers-color-scheme.
mobileBreakpoint number | false 640 Viewport width (px) at/below which the overlay becomes a bottom sheet. false = never.
container HTMLElement document.body Portal target for the overlay.
position 'auto' | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' 'auto' 'auto' flips/shifts to stay in the viewport.
animate boolean true prefers-reduced-motion always wins.
showToday boolean true Show the Today footer button.
showClear boolean true Show the Clear footer button.
confirm boolean | 'auto' 'auto' Show a confirm button. 'auto' = only when the panel is a dialog (sheet or modal), re-resolved live when the viewport crosses mobileBreakpoint.
display 'auto' | 'inline' | 'popup' | 'sheet' | 'modal' 'auto' The form the picker takes. 'auto' = inline on a plain element, popup on a field (bottom sheet below mobileBreakpoint). 'inline' keeps the field and mounts an always-open panel after it; 'popup' | 'sheet' | 'modal' force that overlay — including on a plain element, which then anchors it and toggles on click.
readOnly boolean false Show the value but reject all user changes. The input isn't editable and never opens; an inline panel renders non-interactive (.sdp--readonly).
inputMask boolean true Smart mask on the bound input (single mode only).
classes Partial<Record<SlotName, string>> — Extra class names appended per slot (root, header, day, …).
renderDay (cell: DayCell) => Node — Replace a day cell's content; return nothing for the default. The node type is the layer's own: an HTML string/element in vanilla, a ReactNode, a VNodeChild.
renderWeekday (label: WeekdayLabel) => Node — Replace a weekday header's content. To change only the text, prefer core's weekdays.
vars Record<string, string> — Per-instance theme variables, applied to the panel and the portaled overlay. Bare keys gain the --sdp- prefix.
icons { prev?, next? } chevrons Custom nav icons. Each is an HTML string, an HTMLElement, or (dir) => string | HTMLElement.
dayProps (cell: DayCell) => { className?, title?, attrs? } — Per-day styling hook — add classes/tooltip/attributes. (Disable days via disabledDates.)
renderFooter (ctx: { value, view }) => string | HTMLElement | null — Render custom content at the bottom of the panel (.sdp__note).
outsideDays 'interactive' | 'visible' | 'hidden' 'interactive' Prev/next-month cells: selectable + navigate (default), greyed & inert, or blank. In multi-month views, boundary dates owned by an adjacent pane are always blanked (no duplicates).
highlightToday boolean true Mark today's cell (a ring in the default look). false keeps the .sdp__day--today class — custom CSS, plugin marks and the roving-focus fallback still use it — and drops only the built-in affordance (.sdp--no-today on the panel).
yearPicker boolean true Let the header's year open the year view. false renders the year as plain text (.sdp__title-label--year) instead of a button; the month drill-in is untouched and core.setView('years') still works.
zIndex number 1000 Overlay z-index.
name string — Submit the value with the surrounding <form> (a hidden input.sdp-form-value).
required boolean false Native validation: block submit until a value is chosen.
formValue (value: PickerValue) => string toFormValue Form serialization. Default ISO YYYY-MM-DD (+ THH:mm:ss), range start/end, multiple comma-joined.

Plus one callback per event — see below.

Events

Three ways to reach the same event, derived from one list in the core — so every layer exposes every event, and a new event reaches all of them at once:

picker.on('range:complete', onDone);                  // vanilla: .on()
createDatePicker('#d', { onRangeComplete: onDone });  // vanilla: an option
<DatePicker onRangeComplete={onDone} />               {/* React: a prop */}
<DatePicker @range-complete="onDone" />               <!-- Vue: an emit -->

The name is mechanical: 'range:complete' → onRangeComplete → @range-complete. Every handler receives the core's own payload object (so onChange gets { value, source }, not two arguments). One extra callback that is not a core event, onOpenChange(open: boolean), merges open/close for controlled UIs.

Subscribe imperatively with picker.on(event, handler); once and onAny (catch-all) are also available on the core. Every date in a payload is a DateValue — the same date in every useful shape, so you never convert anything yourself.

Event Payload Fires when
change { value: PickerValue, source: 'select' | 'input' | 'api' | 'clear' | 'time' | 'shortcut' } Every committed value change.
select { date: DateValue } A single day was picked (any mode).
clear { previous: PickerValue } The value was cleared.
range:start { start: DateValue } Range start picked.
range:complete { start: DateValue, end: DateValue } Range end picked.
open / close {} Picker opened / closed.
view:change { view, previous } Switched between days / months / years.
month:change { year, month, direction: 1 | -1 | 0 } Displayed month changed — on every path (nav buttons, goTo, setValue, keyboard paging); 0 = jump.
year:change { year, previous } Displayed year changed — likewise on every path.
locale:change { locale: Locale } Locale switched.
calendar:change { calendar: 'jalali' | 'gregorian' | 'hijri' } Calendar switched.
time:change { time } Time of day changed (time picker on).
shortcut { id, value } A shortcut was applied (its change follows with source 'shortcut').
input { text, complete, valid } Every masked input keystroke.
error { code, message, input?, reason? } invalid-input, out-of-range, disabled (+ reason), range-length, or no-locale-variant.

A real change payload (mode single, locale fa-IR, Nowruz 1405 selected):

picker.on('change', ({ value, source }) => {
  // value.date is a DateValue:
  // {
  //   jalali:    { year: 1405, month: 1, day: 1 },
  //   gregorian: { year: 2026, month: 3, day: 21 },
  //   date:      Date,            // native Date at local midnight
  //   iso:       '2026-03-21',    // Gregorian ISO
  //   jdn:       2461121,         // Julian Day Number
  //   weekday:   6,               // 0 = Sunday … 6 = Saturday
  //   formatted: '۱۴۰۵/۰۱/۰۱',    // active locale + display format
  // }
});

In range mode the value is { mode: 'range', start, end }; in multiple it is { mode: 'multiple', dates }.

Theming

All colors, sizing, radii, and timing are CSS custom properties. The stylesheet reads every token through a private two-tier indirection (--_x: var(--sdp-x, <default>)), so setting a --sdp-* variable on any ancestor — :root, a wrapping element, .sdp itself, or inline — always wins over the built-in default. No class overrides needed:

:root {
  --sdp-accent: #0ea5e9;
  --sdp-on-accent: #ffffff;
  --sdp-radius: 12px;
  --sdp-cell-size: 44px;
}

The --sdp-* names are the public API; --_* names are private and may change.

Popup and sheet overlays are portaled to document.body (or the container option), so they do not inherit variables set on the input's ancestors. For per-instance theming, pass the vars option (bare keys get the --sdp- prefix automatically) or call setVars() at runtime — both apply the variables inline to the panel and every mounted overlay:

const picker = createDatePicker('#date', { vars: { accent: '#e11d48', 'on-accent': '#fff' } });

picker.setVars({ '--sdp-radius': '8px' }); // merges with existing vars
picker.setVars({ accent: '' });            // empty string removes one
Variable Light default Dark default
--sdp-accent #f5b400 #facc15
--sdp-on-accent #1f1600 #1a1400
--sdp-accent-strong color-mix(in srgb, <accent> 58%, #000) <accent>
--sdp-accent-soft color-mix(in srgb, <accent> 28%, transparent) color-mix(in srgb, <accent> 20%, transparent)
--sdp-bg #fffdf8 #18150f
--sdp-surface #f6f2e9 #262119
--sdp-text #1c1917 #f7f4ee
--sdp-muted #78716c #a8a29e
--sdp-border #e8e2d6 #3b3428
--sdp-shadow 0 12px 40px rgba(28,25,23,.14), 0 2px 8px rgba(28,25,23,.06) deeper
--sdp-radius 16px —
--sdp-day-radius 10px —
--sdp-day-gap 3px —
--sdp-cell-size 40px —
--sdp-font system-ui, …, 'Vazirmatn', Tahoma, sans-serif —
--sdp-font-size 14px —
--sdp-duration 200ms —
--sdp-duration-slow 420ms —
--sdp-ease cubic-bezier(.22, 1, .36, 1) —
--sdp-spring damped spring (linear(); cubic fallback) —
--sdp-z 1000 —
--sdp-backdrop rgba(0,0,0,.32) rgba(0,0,0,.55)

Dark mode activates two ways: explicitly via theme: 'dark' (sets data-theme="dark"), or with theme: 'auto' following prefers-color-scheme. Class names are stable BEM with an sdp prefix (.sdp__day--selected, .sdp__nav--prev, …) — see packages/dom/CONTRACT.md for the full DOM/CSS contract.

Input mask

Bound inputs (single mode) get a smart mask, driven by the pure function maskDateInput(raw, { locale, format?, calendar?, caret?, previous? }) from @khorshid-js/core:

  • Type digits only — separators are inserted automatically following the format (numeric tokens YYYY MM DD M D; default is the locale's display format). Typing a separator yourself completes a month/day group early (zero-padding it); separators typed before the year group is complete are ignored. The mask never throws, whatever you type or paste.
  • Persian (۰–۹), Arabic-Indic (٠–٩), and Latin digits are all accepted; the masked text renders in the locale's digits.
  • Impossible values are clamped as you type — months to 1–12, days to the month's length in the active calendar (Esfand 30 in leap years, etc.).
  • Deletion-aware and caret-preserving, so editing mid-value behaves naturally.
  • When the text is complete and forms a valid, allowed date, the selection commits (change with source 'input'). Violations emit error (invalid-input / out-of-range / disabled) and the input gets .sdp-input--invalid.
  • On blur the text normalizes to the canonical formatted value.
  • In range / multiple modes the input is read-only (it shows the formatted value and opens the picker).

Keyboard

Days view:

Key Action
← / → Previous / next day — flipped in RTL locales
↑ / ↓ Previous / next week
PageUp / PageDown Previous / next month
Shift+PageUp / Shift+PageDown Previous / next year
Home / End Start / end of week
Enter / Space Select the focused day
Escape Close the picker

Months and years views: arrows move by ±1 / ±3 (3-column grid), Enter selects.

Accessibility: overlay panels are role="dialog" (aria-modal in sheet mode), the day grid uses ARIA grid roles with a roving tabindex and weekday column headers (role="columnheader") inside the grid, each day has a full-date aria-label, month/year changes are announced through a visually-hidden aria-live region, nav buttons use localized labels (prevMonth/nextMonth in the days view, prevYear/nextYear in the months/years views), focus is trapped inside the bottom sheet, and every animation respects prefers-reduced-motion.

Format tokens

Used by format, display formats, and parsing (mask patterns use the numeric tokens only).

Token Meaning Example (en-US, 1405/01/01)
YYYY 4-digit year 1405
YY 2-digit year 05
MMMM Full month name Farvardin
MMM Short month name Far
MM 2-digit month 01
M Month 1
DD 2-digit day 01
D Day 1
dddd Full weekday name Saturday
ddd Short weekday Sat
dd Min weekday Sa
[…] Literal text [Today:] YYYY/MM/DD

Digits render in the locale's digit set (۱۴۰۵ for fa-IR). Parsing is separator-tolerant (/, -, ., space) and normalizes Persian/Arabic-Indic digits first.

PlainDate

The immutable, calendar-aware date value used throughout (no time, no timezone — the canonical representation is the Julian Day Number, so dates from different calendars compare and convert losslessly):

import { PlainDate } from '@khorshid-js/core';

const nowruz = new PlainDate('jalali', 1405, 1, 1);

nowruz.to('gregorian').toParts(); // { year: 2026, month: 3, day: 21 }
nowruz.toISO();                   // '2026-03-21'  (ISO is always Gregorian)
nowruz.jdn;                       // 2461121
nowruz.weekday;                   // 6  (0 = Sunday … 6 = Saturday)
nowruz.daysInMonth;               // 31

nowruz.add({ months: 6 });        // 1405/07/01 — calendar-aware, day-clamping
nowruz.with({ day: 15 });         // 1405/01/15
nowruz.startOfMonth(); nowruz.endOfMonth();

nowruz.isBefore(other); nowruz.isAfter(other); nowruz.equals(other);
nowruz.isBetween(a, b); nowruz.isSameMonth(other); PlainDate.compare(a, b);

PlainDate.today('jalali');        // today in any calendar
PlainDate.from('2026-03-21');     // ISO string (Gregorian)
PlainDate.from(new Date());       // native Date (local Y/M/D)
PlainDate.from({ year: 1405, month: 1, day: 1, calendar: 'jalali' });

nowruz.format('dddd D MMMM YYYY', faIR); // 'شنبه ۱ فروردین ۱۴۰۵'

Lower-level building blocks are exported too: the gregorian / jalali calendar systems (toJDN, fromJDN, daysInMonth, isLeapYear, isValid), registerCalendar for custom systems, formatDate / parseDate / createDateValue, digit utilities (toLocaleDigits, toLatinDigits, normalizeDigits), and the typed Emitter.

Development

npm install          # workspace root (Node >= 18)
npm run build        # build all packages
npm test             # vitest unit tests
npm run typecheck
packages/
  core/      @khorshid-js/core     — headless engine + the shared UI contract. NO DOM APIs.
  dom/       @khorshid-js/dom      — vanilla UI + styles/khorshid.css + the overlay shells.
  react/     @khorshid-js/react    — React bindings over the same contract.
  vue/       @khorshid-js/vue      — Vue 3 bindings over the same contract.
  holidays/  @khorshid-js/holidays — the official holidays plugin.
  element/   @khorshid-js/element  — <khorshid-datepicker> / <khorshid-date-field> custom elements.
  khorshid/  khorshid           — the one-install umbrella (re-exports, subpath entries).

Contracts, in the order to change them:

  1. packages/core/src/types.ts — the whole API surface, including UIOptions (the one option list), EventHandlers (generated from the event map) and ValueFor<M>. packages/core/src/picker/ui.ts holds the runtime half (UI_DEFAULTS, PICKER_EVENTS, bindEventHandlers), and picker/view-model.ts the shared render model every layer draws from.
  2. packages/dom/CONTRACT.md — DOM structure, class names, CSS variables, animations and the keyboard map, which React and Vue render identically.
  3. The implementations.

Design notes live in ARCHITECTURE.md.

License

MIT

Related Packages

@khorshid-js/vue

Idiomatic Vue 3 bindings for Khorshid — composables + styled components for the...

220
@jalali-js/vue

Vue bindings for jalali-js: useCalendar composable, headless Calendar grid, a de...

1,880 2
vue-jalali-datepicker

A lightweight, zero-dependency Persian (Jalali / Shamsi) date and range picker f...

539 0
@doranjs/vue

Idiomatic Vue 3 bindings for Doran — v-model components over the @doranjs/wc eng...

0 84
vue-persian-calendar-datepicker

An accessible Vue 3 Persian (Jalali) calendar, date picker, and time picker with...

3,651 0

Version History

Version Released Vue License
0.1.0 >=3.4.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage