ad-bs-calendar
v1.2.3
Published
Dual AD / Bikram Sambat (Nepali) date picker for React. Single and range selection, fully themeable with CSS variables, mobile bottom sheet, keyboard and screen-reader accessible.
Maintainers
Keywords
Readme
द ad-bs-calendar
A dual AD / Bikram Sambat date picker for React.
- Three calendars, one payload - render a Gregorian grid, a real BS grid (Baisakh–Chaitra, 29–32 days), or both at once.
onChangealways reports both. - Fully themeable - every colour, size, font, radius and duration is a CSS custom property. No
!importantrequired, ever. - Mobile-first - turns into a modal bottom sheet on small screens, with 44px touch targets and no iOS zoom-on-focus.
- Accessible - combobox + grid ARIA, full keyboard support, screen-reader labels in both calendars, reduced-motion and forced-colours aware.
- Typed - TypeScript declarations ship with the package.
- Small - ~9.7 kB gzipped, zero runtime dependencies.
Install
npm install ad-bs-calendarimport { DualDatePicker } from 'ad-bs-calendar';
import 'ad-bs-calendar/style.css'; // required - the CSS ships separately
function App() {
return <DualDatePicker onChange={(d) => console.log(d)} />;
}Peer dependencies: react and react-dom >= 16.8.
The payload
onChange fires with the same shape regardless of dateMode - the calendar you display never changes the data you receive:
{
ad: '2024-04-13', // Gregorian, YYYY-MM-DD
bs: '2081-01-01', // Bikram Sambat, YYYY-MM-DD
bsDetails: { // full BS breakdown
year: 2081, yearNp: '२०८१',
month: 1, day: 1, dayNp: '१',
monthName: 'Baisakh', monthNameNp: 'बैशाख',
formatted: '2081-01-01'
},
adDate: Date // local midnight
}In pickerMode="range" it fires with { start, end }, where each side is the object above and end is null until the second date is picked. Clearing emits null.
value, defaultValue, minDate and maxDate are always Gregorian "YYYY-MM-DD", in every dateMode. BS is a display concern.
Props
Value
| Prop | Type | Default | |
|---|---|---|---|
| value | string \| Date \| {start, end} \| null | - | Controlled value. Pass {start, end} in range mode. |
| defaultValue | same as value | - | Uncontrolled initial value. |
| onChange | (data) => void | - | See The payload. |
Modes
| Prop | Type | Default | |
|---|---|---|---|
| dateMode | 'dual' \| 'ad' \| 'bs' | 'dual' | dual: AD grid with BS numbers annotated. ad: pure Gregorian, no Devanagari anywhere. bs: a real BS month grid, navigated by BS year/month. |
| pickerMode | 'single' \| 'range' | 'single' | Range turns the grid into a start/end picker. The text input becomes read-only. |
Constraints
| Prop | Type | Default | |
|---|---|---|---|
| minDate / maxDate | string | - | Inclusive bounds, "YYYY-MM-DD". |
| shouldDisableDate | (date, bs) => boolean | - | Return true to make a date unselectable (weekends, holidays, booked slots). Receives the local-midnight Date and its BS breakdown, so Nepali-calendar rules need no conversion. Runs after min/max. |
| required | boolean | false | Native HTML5 validation. |
| disabled | boolean | false | |
Form
| Prop | Type | Default | |
|---|---|---|---|
| id | string | auto | |
| name | string | - | Emits a hidden input with the ISO value (the visible field holds formatted display text). In range mode: ${name}_start and ${name}_end. |
| autoFocus | boolean | false | |
| autoComplete | string | 'off' | |
| placeholder | string | auto | |
Unknown props (aria-label, data-*, onKeyUp, …) are forwarded to the input.
Presentation
| Prop | Type | Default | |
|---|---|---|---|
| className | string | - | On the wrapper. |
| classNames | object | {} | Per-part classes - see Styling. |
| style | CSSProperties | - | On the wrapper. The place for --rdc-* overrides. |
| themeColor | string | - | Shorthand for --rdc-primary + --rdc-selected. |
| theme | 'light' \| 'dark' \| 'auto' | 'light' | auto follows prefers-color-scheme. |
| labels | object | English | Every user-facing string - see Localisation. |
Behaviour
| Prop | Type | Default | |
|---|---|---|---|
| weekStartsOn | 0–6 | 0 | 0 = Sunday. |
| closeOnSelect | boolean | true | |
| presets | Preset[] | - | Quick-select chips in the popover - see Presets. |
| inline | boolean | false | Always-visible calendar, no input, no popover. |
| showFooter | boolean | true | |
| showTodayButton | boolean | true | |
| showClearButton | boolean | true | |
| portal | boolean | false | Render the popover into document.body. Turn on when an ancestor clips (overflow: hidden), creates a containing block (transform, filter), or creates a stacking context (backdrop-filter, z-index cards) that would paint siblings over the popover. |
| portalContainer | Element | document.body | |
| sheetBreakpoint | number | 480 | Viewport width (px) at or below which the popover becomes a bottom sheet. |
| onOpen / onClose | () => void | - | |
Presets
Quick-select chips rendered between the grid and the footer. In single mode a preset's value is a date; in range mode it's { start, end }. A function value is re-evaluated on every render, so relative presets stay correct in long-lived pages:
<DualDatePicker
pickerMode="range"
presets={[
{ label: 'Last 7 days', value: () => ({ start: addDays(new Date(), -6), end: new Date() }) },
{ label: 'This month', value: () => ({
start: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
end: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0)
}) },
{ label: 'Bardiya trip', value: { start: '2026-10-02', end: '2026-10-09' } }
]}
/>Chips that violate minDate/maxDate/shouldDisableDate render disabled. Clicking one commits the value, navigates the grid to it, and (with closeOnSelect) closes the popover.
Disabling dates
shouldDisableDate runs for every rendered day, typed input, preset, and the Today button:
{/* Saturday is Nepal's weekend */}
<DualDatePicker shouldDisableDate={(date) => date.getDay() === 6} />
{/* BS rules work directly - the second argument is the BS breakdown */}
<DualDatePicker shouldDisableDate={(date, bs) => bs.day === 1} />A typed date that hits the callback reports the errorDateUnavailable label ("Date unavailable.") rather than the min/max message.
Styling
Everything visual is a CSS custom property. Override them wherever you like:
/* globally */
:root {
--rdc-primary: #16a34a;
--rdc-radius: 12px;
--rdc-cell-size: 48px;
}/* per instance */
<DualDatePicker style={{ '--rdc-primary': '#dc2626', '--rdc-font-family': 'Inter, sans-serif' }} />
/* or with a class */
<DualDatePicker className="brand-picker" />Tokens come in two kinds. Base tokens (--rdc-primary, --rdc-bg, --rdc-border, …) hold literal defaults. Derived tokens (--rdc-today-dot, --rdc-surface, --rdc-selected, …) default to a base token and are resolved where they're used, so overriding one base token cascades everywhere it feeds:
:root { --rdc-primary: #16a34a; } /* today ring + dot, focus ring, buttons, ornaments, glow */
:root { --rdc-today-dot: #f59e0b; } /* ...or retarget just the one */Both levels work at any scope - global, per class, or inline.
Tokens
Colour
| Token | Default | |
|---|---|---|
| --rdc-primary | #2563eb | Accent: focus rings, today, buttons. |
| --rdc-primary-contrast | #ffffff | Text on --rdc-primary. |
| --rdc-danger | #ef4444 | Errors, clear-hover. |
| --rdc-bg / --rdc-surface | #ffffff | Page / popover background. |
| --rdc-text-main | #1f2937 | |
| --rdc-text-light | #6b7280 | Weekday labels, BS numbers, footer. |
| --rdc-text-disabled | #d1d5db | |
| --rdc-border | #e5e7eb | |
| --rdc-hover | #eff6ff | Cell hover, range fill. |
| --rdc-selected / --rdc-selected-text | --rdc-primary / #ffffff | |
| --rdc-selected-inner-ring | rgb(255 255 255 / 0.3) | |
| --rdc-range-bg / --rdc-range-preview-bg | --rdc-hover | |
| --rdc-disabled-bg / --rdc-disabled-text | #f9fafb / --rdc-text-disabled | |
| --rdc-today-border / --rdc-today-bg / --rdc-today-dot | --rdc-primary / --rdc-hover / --rdc-primary | |
| --rdc-input-bg, --rdc-input-border, --rdc-input-text, --rdc-input-placeholder, --rdc-input-readonly-bg, --rdc-input-disabled-bg | | |
| --rdc-backdrop | rgb(15 23 42 / 0.45) | Bottom-sheet scrim. |
Typography
--rdc-font-family, --rdc-font-size, --rdc-input-font-size, --rdc-select-font-size, --rdc-weekday-font-size, --rdc-day-font-size, --rdc-bs-font-size, --rdc-footer-font-size, --rdc-font-weight-medium, --rdc-font-weight-bold
Metrics
--rdc-width (280px), --rdc-popover-width (300px), --rdc-popover-padding, --rdc-popover-offset, --rdc-input-padding-y, --rdc-input-padding-x, --rdc-cell-size (42px), --rdc-cell-gap, --rdc-radius, --rdc-radius-sm, --rdc-radius-input, --rdc-radius-sheet
Effects
--rdc-shadow, --rdc-shadow-cell-hover, --rdc-focus-ring-width, --rdc-focus-ring-color, --rdc-today-glow, --rdc-today-glow-soft, --rdc-ornament-color, --rdc-ornament-opacity, --rdc-popover-wash, --rdc-footer-wash, --rdc-cell-hover-scale, --rdc-z-index, --rdc-duration, --rdc-duration-popover, --rdc-ease-spring
Set
--rdc-ornament-opacity: 0to drop the decorative corner accents, and--rdc-cell-hover-scale: 1to stop cells lifting on hover.
Class hooks
Built-in classes (rdc-wrapper, rdc-input, rdc-popover, rdc-cell, …) are stable API. To add your own without fighting specificity, use classNames:
<DualDatePicker
classNames={{
wrapper: 'w-full', input: 'ring-1 ring-slate-200',
popover: 'shadow-2xl', day: 'font-mono', todayButton: 'uppercase'
}}
/>Keys: wrapper, input, popover, backdrop, header, navButton, select, grid, weekday, day, presets, presetButton, footer, todayButton, clearButton.
State classes on .rdc-cell: selected, active, today, disabled, range-start, range-end, in-range, in-range-preview.
Dark mode
<DualDatePicker theme="auto" /> {/* or "dark" */}The wrapper and popover carry data-rdc-theme, so you can retarget the tokens yourself:
.rdc-wrapper[data-rdc-theme="dark"] { --rdc-bg: #0b1220; }Mobile
At or below sheetBreakpoint (480px), the popover becomes a modal bottom sheet: backdrop, safe-area padding for the home indicator, body-scroll lock, focus moved into the sheet (which dismisses the on-screen keyboard that would otherwise cover it), and max-height: 90dvh with internal scrolling for landscape.
Handled for you regardless of breakpoint:
- Touch targets - cells grow to 44px and buttons to 40px on
pointer: coarse. - No iOS zoom - the input renders at 16px on coarse pointers; anything smaller makes Safari zoom the viewport on focus and never zoom back.
- No sticky hover - the hover lift is gated behind
hover: hover, so cells don't stay scaled after a tap. - No double-tap delay -
touch-action: manipulationon cells.
Manual typing isn't available in sheet mode (focus lives in the sheet, as with native mobile pickers). Use sheetBreakpoint={0} to keep the popover on all sizes.
Accessibility
- The input is a
combobox(aria-expanded,aria-controls,aria-activedescendant); the popover is adialog; the grid is a realgrid/row/gridcell/columnheadertree. - Every day is labelled in the active calendar(s) - dual mode reads "13 APR 2024 (1 Baisakh 2081 BS)". Today gets
aria-current="date". - A polite live region announces the visible month on navigation.
- Errors surface through
aria-invalidand nativesetCustomValidity. prefers-reduced-motiondisables all animation;forced-colorshands rendering to the OS palette.
Keyboard
| Key | |
|---|---|
| ↑ ↓ ← → | Move by a day / week (opens the picker if closed) |
| Home / End | Start / end of the week (respects weekStartsOn) |
| PageUp / PageDown | ± 1 month (BS months in bs mode) |
| Shift + PageUp/PageDown | ± 1 year |
| Enter | Select the focused day |
| Esc | Close |
Localisation
<DualDatePicker
labels={{
weekdays: ['आइत', 'सोम', 'मंगल', 'बुध', 'बिहि', 'शुक्र', 'शनि'],
today: 'आज',
clear: 'हटाउनुहोस्',
previousMonth: 'अघिल्लो महिना',
nextMonth: 'अर्को महिना',
errorRequired: 'मिति छान्नुहोस्।'
}}
/>Partial overrides merge over the English defaults. Also available: months, calendar, month, year, placeholder, placeholderRange, requiredSuffix, errorRangeRequired, errorFormat, errorInvalidBs, errorInvalidAd, errorOutOfRange, errorDateUnavailable, presets, rangeSeparator.
Recipes
Controlled, in a form
const [dob, setDob] = useState(null);
<form onSubmit={...}>
<DualDatePicker
name="dob" {/* submits ISO via a hidden input */}
value={dob}
onChange={(d) => setDob(d?.ad ?? null)}
maxDate={new Date().toISOString().slice(0, 10)}
required
/>
</form>Range, BS-only, Monday-first
<DualDatePicker pickerMode="range" dateMode="bs" weekStartsOn={1} onChange={({ start, end }) => ...} />Inside a modal or a scroll container
<DualDatePicker portal /> {/* escapes overflow:hidden / transformed ancestors */}Conversion utilities
The AD ↔ BS layer is exported on its own - useful for server-side formatting or validation:
import { adToBs, bsToAd, parseAdDate, formatDate, toNepaliDigits, getBsMonthDays, getBsYearRange } from 'ad-bs-calendar';
adToBs('2024-04-13').formatted // '2081-01-01'
bsToAd(2081, 1, 1) // Date - 2024-04-13, local midnight
parseAdDate('2024-04-13') // Date - local midnight, null if invalid
toNepaliDigits(2081) // '२०८१'
getBsYearRange() // [1970, 2099]
parseAdDateexists becausenew Date("2024-04-13")parses as UTC midnight, which is the previous day in any timezone west of Greenwich. Use it instead of theDateconstructor for date-only strings.
Supported span: 1913-04-13 → 2043-04-13 AD (1970–2099 BS), the extent of the BS month-length table. Dates outside it return { year: null, monthName: 'Out of Range' } from adToBs and are disabled in dual mode.
Development
| | |
|---|---|
| npm run dev | Vite playground (src/dev.jsx) |
| npm test | Vitest suite (103 tests) |
| npm run build | Build dist/ |
License
MIT
