From fc83e8c1a269796f47aac2ab74751f2ae2530fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 19:57:12 +0000 Subject: [PATCH 1/3] fix(types,components): ui:calendar selection shape follows its mode (objectui#10304) `mode: 'multiple'` reads a list of days and `mode: 'range'` reads `{ from, to }`, but both faces declared one day only: the mirror accepted a lone day in multiple mode, which crashed the node, and no document could select a range at all. - types: `value` / `defaultValue` admit one day, a list of days, or a `{ from, to }` range on both faces; the mirror (`CalendarSchema` and `UiCalendarSchema`) refuses a shape its mode does not read, through a refinement on the node. - components: the `ui:calendar` renderer reads each mode's shape and coerces every day through `toDisplayDate`; a misfit shape selects nothing instead of crashing, and is not reshaped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .../calendar.selectionModes-10304.test.tsx | 83 ++++++++++++ .../src/renderers/form/calendar.tsx | 61 ++++++--- ...calendar-date-value-boundary-10293.test.ts | 19 ++- .../calendar-selection-mode-10304.test.ts | 123 ++++++++++++++++++ packages/types/src/form.ts | 41 ++++-- packages/types/src/zod/form.zod.ts | 88 +++++++++++-- 6 files changed, 372 insertions(+), 43 deletions(-) create mode 100644 packages/components/src/renderers/form/__tests__/calendar.selectionModes-10304.test.tsx create mode 100644 packages/types/src/__tests__/calendar-selection-mode-10304.test.ts diff --git a/packages/components/src/renderers/form/__tests__/calendar.selectionModes-10304.test.tsx b/packages/components/src/renderers/form/__tests__/calendar.selectionModes-10304.test.tsx new file mode 100644 index 0000000000..39deaaa645 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/calendar.selectionModes-10304.test.tsx @@ -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[] { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(CLOCK)); + const Calendar = ComponentRegistry.get('ui:calendar')!; + const { container } = render(); + 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']); + }); +}); diff --git a/packages/components/src/renderers/form/calendar.tsx b/packages/components/src/renderers/form/calendar.tsx index 6e729e18d4..54d1d54767 100644 --- a/packages/components/src/renderers/form/calendar.tsx +++ b/packages/components/src/renderers/form/calendar.tsx @@ -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 }) => ( - - ), + ({ schema, className, ...props }: { schema: CalendarSchema; className?: string; [key: string]: any }) => { + const mode = schema.mode || 'single'; + return ( + + ); + }, { namespace: 'ui', // `calendar` collides with the plugin-calendar full CRUD calendar VIEW, diff --git a/packages/types/src/__tests__/calendar-date-value-boundary-10293.test.ts b/packages/types/src/__tests__/calendar-date-value-boundary-10293.test.ts index ed1d06dc49..ccab0b0dde 100644 --- a/packages/types/src/__tests__/calendar-date-value-boundary-10293.test.ts +++ b/packages/types/src/__tests__/calendar-date-value-boundary-10293.test.ts @@ -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'; @@ -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'] >(); }); @@ -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'); }); }); diff --git a/packages/types/src/__tests__/calendar-selection-mode-10304.test.ts b/packages/types/src/__tests__/calendar-selection-mode-10304.test.ts new file mode 100644 index 0000000000..dcd66fd8cf --- /dev/null +++ b/packages/types/src/__tests__/calendar-selection-mode-10304.test.ts @@ -0,0 +1,123 @@ +/** + * 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 — a calendar selection has the shape its `mode` reads. + * + * The date picker behind `ui:calendar` reads one day in `single` mode (the + * default), a LIST of days in `multiple` mode and `{ from, to }` in `range` + * mode. Before this card both faces declared one day only, so the mirror + * accepted `{ mode: 'multiple', value: '2026-09-15' }` — which crashed the + * node — and no document could select a range at all. + * + * Both faces now admit the three shapes at the key; the MIRROR pairs each + * shape with its mode through a refinement on the node, on `CalendarSchema` + * and `UiCalendarSchema` alike. The renderer half is pinned beside it in + * `packages/components`, `calendar.selectionModes-10304.test.tsx`. + */ +import { describe, it, expect } from 'vitest'; +import { CalendarSchema, UiCalendarSchema } from '../zod/form.zod'; +import type { CalendarSchema as TsCalendarSchema } from '../form'; + +const DAY = '2026-09-15'; +const LIST = ['2026-09-15', new Date(2026, 8, 17)]; +const RANGE = { from: '2026-09-15', to: '2026-09-17' }; + +const MIRRORS = [ + ['CalendarSchema', CalendarSchema, 'calendar'], + ['UiCalendarSchema', UiCalendarSchema, 'ui:calendar'], +] as const; + +type Mode = 'single' | 'multiple' | 'range' | undefined; + +/** Which shape each mode reads; `undefined` is the `single` default. */ +const FITS: ReadonlyArray = [ + [undefined, DAY], + ['single', DAY], + ['single', new Date(2026, 8, 15)], + ['multiple', LIST], + ['multiple', []], + ['range', RANGE], + ['range', { from: DAY }], +]; + +/** Every shape on every mode that does not read it. */ +const MISFITS: ReadonlyArray = [ + [undefined, LIST], + [undefined, RANGE], + ['single', LIST], + ['single', RANGE], + ['multiple', DAY], + ['multiple', new Date(2026, 8, 15)], + ['multiple', RANGE], + ['range', DAY], + ['range', new Date(2026, 8, 15)], + ['range', LIST], +]; + +const issuePaths = (result: { success: boolean; error?: { issues: Array<{ path: PropertyKey[] }> } }) => + result.error?.issues.map((issue) => issue.path.join('.')) ?? []; + +describe('calendar selection shape follows `mode` (objectui#10304)', () => { + for (const [name, mirror, type] of MIRRORS) { + for (const key of ['value', 'defaultValue'] as const) { + it(`${name}.${key}: each mode accepts the shape it reads`, () => { + for (const [mode, selection] of FITS) { + const parsed = mirror.safeParse({ type, mode, [key]: selection }); + expect(parsed.success, `${String(mode)} + ${JSON.stringify(selection)}`).toBe(true); + } + }); + + it(`${name}.${key}: a shape its mode does not read is refused at that key`, () => { + for (const [mode, selection] of MISFITS) { + const parsed = mirror.safeParse({ type, mode, [key]: selection }); + expect(parsed.success, `${String(mode)} + ${JSON.stringify(selection)}`).toBe(false); + expect(issuePaths(parsed), `${String(mode)} + ${JSON.stringify(selection)}`).toEqual([key]); + expect(parsed.error?.issues[0]?.code).toBe('custom'); + } + }); + } + } + + it('the card\'s crash document, `multiple` + one day, is refused', () => { + const parsed = UiCalendarSchema.safeParse({ type: 'ui:calendar', mode: 'multiple', value: DAY }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toEqual(['value']); + }); + + it('a range bound under another name is refused rather than selecting nothing', () => { + const parsed = UiCalendarSchema.safeParse({ + type: 'ui:calendar', + mode: 'range', + value: { start: DAY, end: '2026-09-17' }, + }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)[0]).toBe('value'); + }); + + it('a range without `from` is refused', () => { + const parsed = CalendarSchema.safeParse({ type: 'calendar', mode: 'range', value: { to: DAY } }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)[0]).toBe('value'); + }); + + it('value and defaultValue are judged independently', () => { + const parsed = CalendarSchema.safeParse({ type: 'calendar', mode: 'multiple', value: LIST, defaultValue: DAY }); + expect(parsed.success).toBe(false); + expect(issuePaths(parsed)).toEqual(['defaultValue']); + }); + + it('a TypeScript author can write every shape the mirror accepts', () => { + const nodes: TsCalendarSchema[] = [ + { type: 'calendar', mode: 'single', value: DAY }, + { type: 'calendar', mode: 'multiple', value: [DAY, new Date(2026, 8, 17)] }, + { type: 'calendar', mode: 'range', defaultValue: { from: DAY, to: new Date(2026, 8, 17) } }, + ]; + for (const node of nodes) expect(CalendarSchema.safeParse(node).success).toBe(true); + }); +}); diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 5ccd6d2b2a..1bcbb3f3dc 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1145,25 +1145,50 @@ export interface DatePickerSchema extends BaseSchema { children?: never; } +/** + * One calendar day as authored: an ISO 8601 date string (the JSON authoring + * type) or a `Date` (in-process callers). The `calendar` renderer coerces a + * string to a `Date` at its read site; a date-only string (`2026-09-15`) + * selects the day it names in every zone (objectui#10293). + */ +type CalendarDay = Date | string; + +/** + * A `mode: 'range'` selection (objectui#10304): the `{ from, to }` pair the + * range picker reads. `to` is optional — a range whose end is not chosen yet. + */ +interface CalendarDayRange { + from: CalendarDay; + to?: CalendarDay; +} + /** * Calendar component */ export interface CalendarSchema extends BaseSchema { type: 'calendar'; /** - * Default selected date: an ISO 8601 date string (the JSON authoring type) - * or a `Date` (in-process callers). The `calendar` renderer coerces a string - * to a `Date` at its read site; a date-only string (`2026-09-15`) selects - * the day it names in every zone (objectui#10293). + * Default selection. Its shape follows {@link CalendarSchema.mode} + * (objectui#10304), because that is what the date picker reads: + * - `single` (and `mode` absent): one day, an ISO 8601 string or a `Date`; + * - `multiple`: a list of days; + * - `range`: `{ from, to }`, `to` optional. + * + * ⚠️ This declaration admits every shape on every mode: the pairing is a + * property of the node, not of this key, and the MIRROR enforces it — + * `zod/form.zod.ts#CalendarSchema` refuses a value whose shape does not + * fit its mode. The renderer coerces every string day through the shared + * date parse (objectui#10293). */ - defaultValue?: Date | string; + defaultValue?: CalendarDay | CalendarDay[] | CalendarDayRange; /** - * Controlled selected date: an ISO 8601 date string or a `Date`, read as + * Controlled selection, shaped by {@link CalendarSchema.mode} exactly as * {@link CalendarSchema.defaultValue} is. */ - value?: Date | string; + value?: CalendarDay | CalendarDay[] | CalendarDayRange; /** - * Selection mode + * Selection mode. It also decides the shape of `value` / `defaultValue`: + * one day, a list of days, or a `{ from, to }` range (objectui#10304). * @default 'single' */ mode?: 'single' | 'multiple' | 'range'; diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index c8e95e7b5d..641dfaeed9 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -544,18 +544,75 @@ export const DatePickerSchema = BaseSchema.extend({ ), }); +// objectui#10293: one calendar day. The string arm is the JSON authoring +// type, an ISO 8601 date string, which the `calendar` renderer coerces to a +// `Date`. The `z.date()` arm stays for in-process callers, as on +// `DatePickerSchema`. +const CalendarDaySchema = z.union([z.string(), z.date()]); + +// objectui#10304: the `{ from, to }` pair `mode: 'range'` reads. Strict, so a +// misspelled bound (`start` / `end`) is refused instead of selecting nothing. +const CalendarDayRangeSchema = z.strictObject({ + from: CalendarDaySchema, + to: CalendarDaySchema.optional(), +}); + +// objectui#10304: every selection shape the date picker reads. WHICH one a node +// may carry is decided by its `mode`, and `calendarSelectionFitsMode` below +// holds that pairing; this union alone is the key-level set. +const CalendarSelectionSchema = z.union([ + CalendarDaySchema, + z.array(CalendarDaySchema), + CalendarDayRangeSchema, +]); + /** - * Calendar Schema - Calendar component + * objectui#10304 — a selection must have the shape its `mode` reads. + * + * The date picker reads one day in `single` mode (the default), a LIST in + * `multiple` mode and `{ from, to }` in `range` mode. A document whose value + * does not fit crashed the node (`multiple` given one day threw inside the + * picker) or selected nothing without a word (`range` given anything but a + * pair). The pairing spans two keys, so it is a refinement on the node rather + * than a type on either key. */ -export const CalendarSchema = BaseSchema.extend({ +const calendarSelectionFitsMode = ( + node: { mode?: 'single' | 'multiple' | 'range'; value?: unknown; defaultValue?: unknown }, + ctx: z.RefinementCtx, +): void => { + const mode = node.mode ?? 'single'; + for (const key of ['value', 'defaultValue'] as const) { + const selection = node[key]; + if (selection === undefined) continue; + const isList = Array.isArray(selection); + const isRange = !isList && typeof selection === 'object' && !(selection instanceof Date); + const fits = mode === 'multiple' ? isList : mode === 'range' ? isRange : !isList && !isRange; + if (fits) continue; + const expected = mode === 'multiple' + ? 'a list of days (ISO 8601 date strings)' + : mode === 'range' + ? 'a `{ from, to }` range of days (ISO 8601 date strings)' + : 'one day (an ISO 8601 date string)'; + ctx.addIssue({ + code: 'custom', + path: [key], + message: `\`${key}\` does not fit \`mode: '${mode}'\`, which selects ${expected} (objectui#10304)`, + }); + } +}; + +/** + * Calendar key set, shared by {@link CalendarSchema} and + * {@link UiCalendarSchema}. Unexported: each public schema adds the + * mode/selection refinement itself, because zod refuses to `.extend()` an + * object that already carries one. + */ +const CalendarObjectSchema = BaseSchema.extend({ type: z.literal('calendar'), - // objectui#10293: the string arm is the JSON authoring type, an ISO 8601 - // date string, which the `calendar` renderer coerces to a `Date`. The - // `z.date()` arm stays for in-process callers, as on `DatePickerSchema`. - defaultValue: z.union([z.string(), z.date()]).optional() - .describe('Default selected date, an ISO 8601 date string'), - value: z.union([z.string(), z.date()]).optional() - .describe('Controlled selected date, an ISO 8601 date string'), + defaultValue: CalendarSelectionSchema.optional() + .describe("Default selection: one ISO 8601 day, a list of days for mode 'multiple', or { from, to } for mode 'range'"), + value: CalendarSelectionSchema.optional() + .describe("Controlled selection: one ISO 8601 day, a list of days for mode 'multiple', or { from, to } for mode 'range'"), mode: z.enum(['single', 'multiple', 'range']).optional().describe('Selection mode'), minDate: z.union([z.string(), z.date()]).optional().describe('Minimum date'), maxDate: z.union([z.string(), z.date()]).optional().describe('Maximum date'), @@ -590,6 +647,11 @@ export const CalendarSchema = BaseSchema.extend({ ), }); +/** + * Calendar Schema - Calendar component + */ +export const CalendarSchema = CalendarObjectSchema.superRefine(calendarSelectionFitsMode); + /** * Input OTP Schema - One-time password input */ @@ -1047,12 +1109,14 @@ export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: tr * The key set is {@link CalendarSchema}'s: `calendar.tsx` reads `schema.mode`, * `schema.value`, `schema.defaultValue` and `className`, which is what that * schema already declares — it mirrors this primitive's shape while its own - * literal resolves to the plugin view. + * literal resolves to the plugin view. It is built from the same unexported + * key set and carries the same mode/selection refinement (objectui#10304), so + * the two spellings accept exactly the same selections. */ -export const UiCalendarSchema = CalendarSchema.extend({ +export const UiCalendarSchema = CalendarObjectSchema.extend({ type: z.literal('ui:calendar') .describe('The `ui`-namespaced date-picker primitive — `calendar` alone names the plugin-calendar view'), -}); +}).superRefine(calendarSelectionFitsMode); /** * Form Component Schema Union - All form component schemas From b1c42ef2ebe437ea903a9378b1c5c913c5d970dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 20:04:10 +0000 Subject: [PATCH 2/3] test(components): pin list and range day coercion west of UTC (objectui#10304) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .../calendar.dateValueZone-10293.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx b/packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx index 29759fc1bc..0a08ba9467 100644 --- a/packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx +++ b/packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx @@ -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']); + }); +}); From dd123d7f3b6cd770934654126e1649097687b214 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 20:09:05 +0000 Subject: [PATCH 3/3] docs(components): calendar selection shape per mode, and changeset (objectui#10304) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .changeset/10304-calendar-selection-modes.md | 9 ++++++++ content/docs/components/form/calendar.mdx | 24 +++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/10304-calendar-selection-modes.md diff --git a/.changeset/10304-calendar-selection-modes.md b/.changeset/10304-calendar-selection-modes.md new file mode 100644 index 0000000000..46a02728cc --- /dev/null +++ b/.changeset/10304-calendar-selection-modes.md @@ -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. diff --git a/content/docs/components/form/calendar.mdx b/content/docs/components/form/calendar.mdx index 1d67c0708c..e228c688ed 100644 --- a/content/docs/components/form/calendar.mdx +++ b/content/docs/components/form/calendar.mdx @@ -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 @@ -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