본문으로 건너뛰기

Date adapters & the /headless entry

@kalyx/react ships with date-fns wired up out of the box. If you're starting fresh, you don't need to think about adapters — install, import, render, done.

This guide is for the second case: you already ship dayjs, luxon, or Temporal in your app, and you'd rather not bundle a second date library just because Kalyx is here.

미리 만들어진 어댑터 패키지

직접 만들기 전에, Kalyx가 이미 제공하는 어댑터가 있는지 확인하세요. 각각은 공유 conformance 스위트(@kalyx/core/test-helpers)로 검증된, UTC로 고정된 얇은 DateAdapter이므로 기본 어댑터와 동일하게 동작합니다:

패키지백엔드사용 시점
@kalyx/adapter-date-fnsdate-fns기본값. @kalyx/react가 자동 설치.
@kalyx/adapter-dayjsdayjs이미 dayjs를 쓰는 경우 (Mantine 등 다수 스택).
@kalyx/adapter-luxonluxon이미 luxon을 쓰는 경우 (엔터프라이즈 / timezone 중심 스택에서 흔함).
npm install @kalyx/adapter-luxon # or @kalyx/adapter-dayjs
import { DatePicker } from '@kalyx/react/headless';
import { LuxonAdapter } from '@kalyx/adapter-luxon';

<DatePicker adapter={LuxonAdapter} value={iso} onChange={setIso}>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>

셋 모두 모든 연산을 UTC로 수행해 동일한 ISO 8601(...Z) 시맨틱을 지키며, timezone 관련 작업은 @kalyx/core에 위임합니다. 정확성 로직은 각 어댑터가 아니라 core에 있습니다. 백엔드가 맞는 게 없으면 아래 인터페이스로 직접 작성하세요.


Default (date-fns)

npm install @kalyx/react
import { DatePicker } from '@kalyx/react';

<DatePicker value={iso} onChange={setIso}>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>

기본 엔트리는 DateFnsAdapter를 자동 설치하므로 필요한 date-fns 함수가 소비자 그래프에 포함되고 캘린더가 즉시 동작합니다. 정확한 비용은 주변 의존성 그래프에 따라 달라지므로 애플리케이션 번들러로 측정하세요.

This is the right choice for most apps. Keep reading only if you have a reason to switch.


Why would I switch?

You should switch to the /headless entry when:

  • You already ship dayjs / luxon / Temporal. Bundling date-fns alongside is dead weight — a second parser, a second arithmetic engine, a second formatter.
  • You need a date library Kalyx doesn't bundle. Pass your own adapter and Kalyx will route every date operation through it.
  • You need a deterministic clock for tests. A stub adapter whose today() always returns the same ISO string makes calendar snapshots stable.

If none of these apply, stay on the default — switching costs more bytes of your attention than it saves of your bundle.


Using a custom adapter

Import from @kalyx/react/headless and pass an adapter prop. The component surface is otherwise identical to @kalyx/react:

import { DatePicker } from '@kalyx/react/headless';
import { DateFnsAdapter } from '@kalyx/adapter-date-fns';
// or a prebuilt one: import { LuxonAdapter } from '@kalyx/adapter-luxon';
// or your own: import { MyAdapter } from './my-adapter';

<DatePicker adapter={DateFnsAdapter} value={iso} onChange={setIso}>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>

Every Root component (DatePicker, RangePicker, TimePicker, DateTimePicker, MonthPicker, YearPicker, WeekPicker) accepts the same adapter prop. Every hook (useDatePicker, useRangePicker, useTimePicker) accepts an adapter option.

If you forget the adapter prop on the headless entry, the Root throws a clear error at render time:

[@kalyx/react/headless] DatePicker requires an adapter.
Pass one via <DatePicker adapter={...}>.
If you don't need a custom adapter, import from '@kalyx/react' instead.

This is intentional — catching the mistake at render is much friendlier than crashing later inside a addMonths call with a stack trace pointing at the Calendar grid.

Mixing entries

You can use @kalyx/react (with the default adapter) for most of your app and @kalyx/react/headless (with a custom adapter) for the one screen that needs it. They compose freely — the component implementations are the same code, only the default-adapter installation differs.


Writing your own adapter

If your date library isn't already covered by a prebuilt package (date-fns, dayjs, luxon, see the section above), implement the DateAdapter interface yourself. It has 21 methods. All of them take ISO 8601 UTC strings as input and return either ISO strings, booleans, or numbers. Native Date objects never cross the boundary.

import type { DateAdapter } from '@kalyx/react/headless';

interface DateAdapter {
// Parsing & formatting
parse(value: string, format?: string): string;
format(iso: string, formatStr: string, timezone?: string): string;

// Arithmetic
addDays(iso: string, n: number): string;
addMonths(iso: string, n: number): string;
addYears(iso: string, n: number): string;

// Comparison
isBefore(a: string, b: string): boolean;
isAfter(a: string, b: string): boolean;
isSameDay(a: string, b: string, timezone?: string): boolean;
isSameMonth(a: string, b: string): boolean;

// Boundaries
startOfDay(iso: string, timezone?: string): string;
startOfMonth(iso: string): string;
endOfMonth(iso: string): string;
startOfWeek(iso: string, weekStartsOn?: 0 | 1): string;
endOfWeek(iso: string, weekStartsOn?: 0 | 1): string;

// Clock
now(): string; // Current instant
today(timezone?: string): string; // Civil midnight in the given zone

// Validation
isValid(value: string): boolean;

// Component access
getYear(iso: string): number;
getMonth(iso: string): number; // 0-indexed (matches Date.getUTCMonth)
getDate(iso: string): number;
getDay(iso: string): number; // 0=Sunday
}

dayjs reference implementation

Sketch — works for most non-DST-edge use cases. Install dayjs, dayjs/plugin/utc, dayjs/plugin/timezone, dayjs/plugin/customParseFormat.

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import type { DateAdapter } from '@kalyx/react/headless';

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);

// date-fns tokens (yyyy, MM, dd, HH, mm) → dayjs tokens (YYYY, MM, DD, HH, mm).
// Kalyx passes date-fns-style format strings everywhere, so we translate at the edge.
function toDayjsFormat(fmt: string): string {
return fmt
.replace(/yyyy/g, 'YYYY')
.replace(/yy/g, 'YY')
.replace(/dd/g, 'DD')
.replace(/d/g, 'D');
}

export const DayjsAdapter: DateAdapter = {
parse: (value, format) =>
format ? dayjs.utc(value, toDayjsFormat(format)).toISOString() : dayjs.utc(value).toISOString(),

format: (iso, fmt, tz) => {
const d = tz ? dayjs.utc(iso).tz(tz) : dayjs.utc(iso);
return d.format(toDayjsFormat(fmt));
},

addDays: (iso, n) => dayjs.utc(iso).add(n, 'day').toISOString(),
addMonths: (iso, n) => dayjs.utc(iso).add(n, 'month').toISOString(),
addYears: (iso, n) => dayjs.utc(iso).add(n, 'year').toISOString(),

isBefore: (a, b) => dayjs.utc(a).isBefore(dayjs.utc(b)),
isAfter: (a, b) => dayjs.utc(a).isAfter(dayjs.utc(b)),
isSameDay: (a, b, tz) => {
const da = tz ? dayjs.utc(a).tz(tz) : dayjs.utc(a);
const db = tz ? dayjs.utc(b).tz(tz) : dayjs.utc(b);
return da.isSame(db, 'day');
},
isSameMonth: (a, b) => dayjs.utc(a).isSame(dayjs.utc(b), 'month'),

startOfDay: (iso, tz) => {
const d = tz ? dayjs.utc(iso).tz(tz) : dayjs.utc(iso);
return d.startOf('day').toISOString();
},
startOfMonth: (iso) => dayjs.utc(iso).startOf('month').toISOString(),
endOfMonth: (iso) => dayjs.utc(iso).endOf('month').toISOString(),
startOfWeek: (iso, weekStartsOn = 0) => {
// dayjs.startOf('week') is locale-dependent. Compute manually to match Kalyx's
// weekStartsOn contract (0 = Sunday, 1 = Monday).
const d = dayjs.utc(iso);
const dow = d.day();
const diff = (dow - weekStartsOn + 7) % 7;
return d.subtract(diff, 'day').startOf('day').toISOString();
},
endOfWeek: (iso, weekStartsOn = 0) => {
const d = dayjs.utc(iso);
const dow = d.day();
const diff = (weekStartsOn + 6 - dow + 7) % 7;
return d.add(diff, 'day').endOf('day').toISOString();
},

now: () => dayjs.utc().toISOString(),
today: (tz) => {
const d = tz ? dayjs().tz(tz) : dayjs.utc();
return d.startOf('day').toISOString();
},

isValid: (v) => dayjs(v).isValid(),

getYear: (iso) => dayjs.utc(iso).year(),
getMonth: (iso) => dayjs.utc(iso).month(), // already 0-indexed
getDate: (iso) => dayjs.utc(iso).date(),
getDay: (iso) => dayjs.utc(iso).day(),
};

Then:

import { DatePicker } from '@kalyx/react/headless';
import { DayjsAdapter } from './my-dayjs-adapter';

<DatePicker adapter={DayjsAdapter} value={iso} onChange={setIso}>
<DatePicker.Calendar />
</DatePicker>

Things to get right

  • Always return ISO 8601 UTC strings (ending in Z). Local-time strings will silently drift on the next operation.
  • getMonth is 0-indexed. Match Date.getUTCMonth(). luxon's .month is 1-indexed — subtract 1.
  • startOfDay / today take a timezone. When provided, return the civil-midnight instant of that zone, not UTC midnight. Without it, return UTC midnight of the same calendar day. The TimePicker and Calendar both rely on this distinction across DST boundaries.
  • format tokens follow date-fns (yyyy, MM, dd, HH, mm). If your library uses different tokens, translate at the adapter boundary as shown above.

Testing your adapter

The fastest sanity check is to render <DatePicker.Calendar /> with the adapter and step through a month with arrow keys. If the dates align with what your library reports, the contract holds.

For full confidence, run the shared conformance suite. @kalyx/core/test-helpers exports runAdapterConformanceTests, the exact suite the prebuilt adapters are validated against. It covers leap years, DST transitions, end-of-month rollover, and the weekday / month-index conventions:

import { describe, it, expect } from 'vitest';
import { runAdapterConformanceTests } from '@kalyx/core/test-helpers';
import { MyAdapter } from './my-adapter';

runAdapterConformanceTests(MyAdapter, { describe, it, expect });

If every case passes, your adapter satisfies the same contract as @kalyx/adapter-date-fns, @kalyx/adapter-dayjs, and @kalyx/adapter-luxon.


Next