Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/10304-calendar-selection-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/types': minor
'@object-ui/components': minor
---

`ui:calendar`: the selection shape follows `mode` (objectui#10304).

- `@object-ui/types`: `CalendarSchema.value` / `.defaultValue` (and the `UiCalendarSchema` mirror) now admit one day, a list of days, or a `{ from, to }` range, where a day is an ISO 8601 date string or a `Date`. The zod mirror pairs each shape with its mode: `single` (the default) takes one day, `multiple` a list, `range` a `{ from, to }` object with `to` optional and no other key. Breaking for documents that validated before: a single day under `mode: 'multiple'` or `mode: 'range'` is now refused at the key, where it used to pass validation and then crash the node (`multiple`) or select nothing (`range`).
- `@object-ui/components`: the `ui:calendar` renderer reads each mode's shape and coerces every day in it through `toDisplayDate`, so date-only strings in a list or a range select the days they name in every time zone. A range now selects its days. A value whose shape does not fit its mode selects nothing instead of throwing, and is not reshaped.
24 changes: 21 additions & 3 deletions content/docs/components/form/calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,30 @@ The Calendar component allows users to select dates in various modes including s

## Selected Date

`value` and `defaultValue` take an ISO 8601 date string in JSON (`"2026-09-15"` or `"2026-09-15T09:00:00Z"`); a TypeScript caller may also pass a `Date`. A date-only string selects the day it names in every time zone.
Each day in `value` and `defaultValue` is an ISO 8601 date string in JSON (`"2026-09-15"` or `"2026-09-15T09:00:00Z"`); a TypeScript caller may also pass a `Date`. A date-only string selects the day it names in every time zone.

The shape of the selection follows `mode`:

| `mode` | `value` / `defaultValue` |
|---|---|
| `single` (default) | one day |
| `multiple` | a list of days |
| `range` | `{ "from": …, "to": … }` — `to` is optional |

```json
{ "type": "ui:calendar", "mode": "single", "defaultValue": "2026-09-15" }
```

```json
{ "type": "ui:calendar", "mode": "multiple", "defaultValue": ["2026-09-15", "2026-09-17"] }
```

```json
{ "type": "ui:calendar", "mode": "range", "defaultValue": { "from": "2026-09-15", "to": "2026-09-19" } }
```

Schema validation refuses a selection whose shape does not fit its mode — a single day under `mode: "multiple"`, for example — instead of letting it reach the renderer.

## Schema

```plaintext
Expand All @@ -38,8 +56,8 @@ interface CalendarSchema {

// Selection
mode?: 'single' | 'multiple' | 'range'; // Selection mode (default: 'single')
defaultValue?: Date | string; // Default selected date (ISO 8601 string in JSON)
value?: Date | string; // Controlled selected date (ISO 8601 string in JSON)
defaultValue?: Day | Day[] | { from: Day; to?: Day }; // Default selection, shaped by mode (Day = ISO 8601 string in JSON, or Date)
value?: Day | Day[] | { from: Day; to?: Day }; // Controlled selection, shaped by mode

// Constraints
minDate?: Date | string; // Minimum selectable date
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,29 @@ describe.runIf(DRIVEN)('ui:calendar date-only value east of UTC, the control (ob
expect(selectedDays({ value: INSTANT })).toEqual(['2026-09-16']);
});
});

/**
* objectui#10304: `multiple` and `range` read a list and a `{ from, to }` pair,
* and every day inside them takes the same coercion. In the suite zone the
* engine parse already lands on the named day, so only a zone west of UTC can
* tell the element-wise coercion from its absence.
*/
describe.runIf(DRIVEN)('ui:calendar date-only days inside a list or a range, west of UTC (objectui#10304)', () => {
it('rig: the zone really moved', () => {
enter(WEST);
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
expect(new Date(INSTANT).getHours()).toBe(21);
});

it('multiple: each date-only day selects the day it names', () => {
enter(WEST);
expect(selectedDays({ mode: 'multiple', value: [DATE_ONLY, '2026-09-17'] }))
.toEqual(['2026-09-15', '2026-09-17']);
});

it('range: `{ from, to }` of date-only days selects exactly the days they name', () => {
enter(WEST);
expect(selectedDays({ mode: 'range', value: { from: DATE_ONLY, to: '2026-09-17' } }))
.toEqual(['2026-09-15', '2026-09-16', '2026-09-17']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#10304 — `ui:calendar` reads the selection shape its `mode` reads.
*
* `DayPicker` reads one day in `single` mode, a list in `multiple` mode and
* `{ from, to }` in `range` mode. The renderer handed every mode the one-day
* read, so `multiple` given a lone day threw inside the picker
* (`selected?.some is not a function`) and `range` selected nothing for any
* value at all. The renderer now reads each mode's shape and coerces every
* day inside it through `toDisplayDate`; the mirror half, which refuses a
* shape its mode does not read, is `calendar-selection-mode-10304.test.ts` in
* `packages/types`.
*
* Dates here are date-only strings in the suite zone; the zone behaviour of
* the per-day coercion is pinned by `calendar.dateValueZone-10293.test.tsx`.
*/
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a hook (objectui#3010).
import '../../../renderers';

/** Frozen inside September 2026, so the calendar opens on the month under test. */
const CLOCK = '2026-09-03T10:35:00.000Z';

afterEach(() => {
cleanup();
vi.useRealTimers();
});

/** The `data-day` of every selected cell the calendar paints. */
function selectedDays(schema: Record<string, unknown>): string[] {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(CLOCK));
const Calendar = ComponentRegistry.get('ui:calendar')!;
const { container } = render(<Calendar schema={{ type: 'ui:calendar', ...schema }} />);
const days = Array.from(container.querySelectorAll('td[data-selected="true"]'))
.map((td) => td.getAttribute('data-day') ?? '');
cleanup();
return days;
}

describe('ui:calendar selection by mode (objectui#10304)', () => {
it('range + { from, to } selects every day of the range, through value and defaultValue', () => {
const range = { from: '2026-09-15', to: '2026-09-17' };
const expected = ['2026-09-15', '2026-09-16', '2026-09-17'];
expect(selectedDays({ mode: 'range', value: range })).toEqual(expected);
expect(selectedDays({ mode: 'range', defaultValue: range })).toEqual(expected);
});

it('range with `from` only selects that one day', () => {
expect(selectedDays({ mode: 'range', value: { from: '2026-09-15' } })).toEqual(['2026-09-15']);
});

it('multiple + a list selects each listed day, strings and `Date`s alike', () => {
expect(selectedDays({ mode: 'multiple', value: ['2026-09-15', new Date(2026, 8, 20)] }))
.toEqual(['2026-09-15', '2026-09-20']);
expect(selectedDays({ mode: 'multiple', defaultValue: ['2026-09-02'] })).toEqual(['2026-09-02']);
});

it('multiple + a lone day no longer crashes, and selects nothing rather than being reshaped', () => {
expect(() => selectedDays({ mode: 'multiple', value: '2026-09-15' })).not.toThrow();
expect(selectedDays({ mode: 'multiple', value: '2026-09-15' })).toEqual([]);
expect(selectedDays({ mode: 'multiple', defaultValue: new Date(2026, 8, 15) })).toEqual([]);
});

it('range + a lone day selects nothing', () => {
expect(selectedDays({ mode: 'range', value: '2026-09-15' })).toEqual([]);
});

it('single mode keeps selecting its one day', () => {
expect(selectedDays({ mode: 'single', value: '2026-09-15' })).toEqual(['2026-09-15']);
expect(selectedDays({ value: '2026-09-15' })).toEqual(['2026-09-15']);
});
});
61 changes: 45 additions & 16 deletions packages/components/src/renderers/form/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,55 @@ import type { CalendarSchema } from '@object-ui/types';
import { Calendar } from '../../ui';

/**
* The selected-date read (objectui#10293). JSON authors an ISO 8601 string
* and `DayPicker` compares `Date`s, so a string is coerced here, through the
* shared parse step: a date-only string is rebuilt at LOCAL midnight of the
* day it names, where the engine's own parse read it as UTC midnight and
* selected the day before west of UTC (the objectui#10183 convention). Any
* other value — a `Date`, or nothing — reaches `DayPicker` unchanged.
* One authored day, as `DayPicker` compares it (objectui#10293). JSON authors
* an ISO 8601 string and `DayPicker` compares `Date`s, so a string is coerced
* here, through the shared parse step: a date-only string is rebuilt at LOCAL
* midnight of the day it names, where the engine's own parse read it as UTC
* midnight and selected the day before west of UTC (the objectui#10183
* convention). A `Date` passes unchanged; anything else is no day.
*/
function toSelected(value: unknown): unknown {
return typeof value === 'string' ? toDisplayDate(value) : value;
function toDay(value: unknown): Date | undefined {
if (typeof value === 'string') return toDisplayDate(value);
return value instanceof Date ? value : undefined;
}

/**
* The selection read (objectui#10304). `DayPicker` reads one day in `single`
* mode, a list in `multiple` mode and `{ from, to }` in `range` mode, which is
* the shape `CalendarSchema`'s mirror requires per mode; every day inside is
* coerced through {@link toDay}.
*
* A value whose shape does not fit its mode is refused at authoring time by
* that mirror. One that reaches the renderer anyway selects nothing — it is
* NOT reshaped into the mode's form (a lone day is not wrapped into a list),
* which would make an off-contract document work (AGENTS.md #0.1). Selecting
* nothing replaces the crash `multiple` mode hit on a lone day.
*/
function toSelection(mode: string, value: unknown): unknown {
if (value === undefined || value === null) return undefined;
if (mode === 'multiple') {
return Array.isArray(value) ? value.map(toDay).filter((day): day is Date => day !== undefined) : undefined;
}
if (mode === 'range') {
if (typeof value !== 'object' || Array.isArray(value) || value instanceof Date) return undefined;
const { from, to } = value as { from?: unknown; to?: unknown };
return { from: toDay(from), to: toDay(to) };
}
return toDay(value);
}

ComponentRegistry.register('calendar',
({ schema, className, ...props }: { schema: CalendarSchema; className?: string; [key: string]: any }) => (
<Calendar
mode={(schema.mode || "single") as any}
selected={toSelected(schema.value || schema.defaultValue) as any}
className={className}
{...props}
/>
),
({ schema, className, ...props }: { schema: CalendarSchema; className?: string; [key: string]: any }) => {
const mode = schema.mode || 'single';
return (
<Calendar
mode={mode as any}
selected={toSelection(mode, schema.value || schema.defaultValue) as any}
className={className}
{...props}
/>
);
},
{
namespace: 'ui',
// `calendar` collides with the plugin-calendar full CRUD calendar VIEW,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
*
* The type-level half reddens under `tsc -p tsconfig.test.json` (this
* package's `type-check`), not under vitest; the runtime half reddens here.
*
* objectui#10304 then made both keys mode-dependent: a list for
* `mode: 'multiple'`, `{ from, to }` for `mode: 'range'`. The DAY this file
* pins is unchanged — it is now the element type of every shape — and a list
* is refused only where the mode does not read one; the per-mode pins are in
* `calendar-selection-mode-10304.test.ts`.
*/
import { describe, it, expect, expectTypeOf } from 'vitest';
import type { z } from 'zod';
Expand All @@ -32,9 +38,11 @@ import type { CalendarSchema as TsCalendarSchema } from '../form';
type MirrorInput = z.input< typeof CalendarSchema >;

describe('CalendarSchema date-value keys: one face on each side of the boundary (objectui#10293)', () => {
it('the declaration and the mirror admit the same set, `Date | string`', () => {
expectTypeOf< TsCalendarSchema['value'] >().toEqualTypeOf< Date | string | undefined >();
expectTypeOf< TsCalendarSchema['defaultValue'] >().toEqualTypeOf< Date | string | undefined >();
it('the declaration and the mirror admit the same set, a `Date | string` day in every shape', () => {
type Day = Date | string;
type Selection = Day | Day[] | { from: Day; to?: Day } | undefined;
expectTypeOf< TsCalendarSchema['value'] >().toEqualTypeOf< Selection >();
expectTypeOf< TsCalendarSchema['defaultValue'] >().toEqualTypeOf< Selection >();
expectTypeOf< MirrorInput['value'] >().toEqualTypeOf< TsCalendarSchema['value'] >();
expectTypeOf< MirrorInput['defaultValue'] >().toEqualTypeOf< TsCalendarSchema['defaultValue'] >();
});
Expand All @@ -52,13 +60,10 @@ describe('CalendarSchema date-value keys: one face on each side of the boundary
}
});

it('the mirror refuses a list — the arm the declaration gave up', () => {
it('the mirror refuses a list in single mode — a list is `mode: \'multiple\'` only (objectui#10304)', () => {
const list = ['2026-09-15', '2026-09-17'];
const parsed = CalendarSchema.safeParse({ type: 'calendar', value: list });
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((issue) => issue.path.join('.'))).toEqual(['value']);
// @ts-expect-error — `Date[]` left the declaration with objectui#10293.
const listNode: TsCalendarSchema = { type: 'calendar', value: [new Date(2026, 8, 15)] };
expect(listNode.type).toBe('calendar');
});
});
Loading
Loading