diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts
index 5ab9640..777103c 100644
--- a/packages/jsonforms-renderers/dev/fixtures.ts
+++ b/packages/jsonforms-renderers/dev/fixtures.ts
@@ -421,7 +421,12 @@ export const fixtures: Fixture[] = [
properties: {
eventDate: { type: 'string', format: 'date', description: 'Date only (yyyy-MM-dd)' },
appointment: { type: 'string', format: 'date-time', description: 'Date + time (RFC 3339)' },
- openingTime: { type: 'string', format: 'time', description: 'Time only (native picker)' },
+ openingTime: { type: 'string', format: 'time', description: 'Time only (native picker, minute precision)' },
+ closingTime: {
+ type: 'string',
+ format: 'time',
+ description: 'Time only with seconds spinner (options.showSeconds)',
+ },
},
required: ['eventDate'],
},
@@ -431,6 +436,7 @@ export const fixtures: Fixture[] = [
{ type: 'Control', scope: '#/properties/eventDate' },
{ type: 'Control', scope: '#/properties/appointment' },
{ type: 'Control', scope: '#/properties/openingTime' },
+ { type: 'Control', scope: '#/properties/closingTime', options: { showSeconds: true } },
],
} as UISchemaElement,
},
diff --git a/packages/jsonforms-renderers/docs/date-control.md b/packages/jsonforms-renderers/docs/date-control.md
new file mode 100644
index 0000000..8aa5e46
--- /dev/null
+++ b/packages/jsonforms-renderers/docs/date-control.md
@@ -0,0 +1,40 @@
+# Date and time (`DateControl`)
+
+`DateControl` renders JSON Schema `string` fields with `format` of `date`, `time`, or `date-time`, using native browser pickers.
+
+See the `date` fixture in `dev/fixtures.ts` for a runnable example (`eventDate`, `appointment`, `openingTime`, `closingTime`).
+
+## Formats
+
+| Schema `format` | Stored value | UI |
+| --------------- | ------------------------------------------- | -------------------------------------------------------- |
+| `date` | `YYYY-MM-DD` | Native date input |
+| `time` | RFC 3339 `HH:MM:SS` | Native time input |
+| `date-time` | RFC 3339 date-time (seconds + local offset) | Date and time inputs side by side, sharing width equally |
+
+`time` and `date-time` share the same native time input. With `date` alone the date input expands to the full control width.
+
+## Stored time is always `HH:MM:SS`
+
+AJV's `format: "time"` is RFC 3339, which requires seconds. Native `` with minute precision (the default) emits `HH:MM`. `DateControl` pads that to `HH:MM:SS` before writing form data (`14:30` → `14:30:00`) via [`utils/time.ts`](../src/utils/time.ts). Clearing the picker still clears the field.
+
+Artifact schemas that declare `format: "time"` do not need to change.
+
+## Seconds spinner (`options.showSeconds`)
+
+The seconds spinner is **opt-in**. Set `options.showSeconds: true` on the **Control** in the uischema — it applies to both `time` and `date-time`:
+
+```json
+{
+ "type": "Control",
+ "scope": "#/properties/proposed_inspection_time",
+ "options": { "showSeconds": true }
+}
+```
+
+| `showSeconds` | Picker | Stored value |
+| ----------------- | ------------------------------------ | ------------------------------------------------------------------- |
+| omitted / `false` | Hour and minute only (`step=60`) | Seconds default to `00` |
+| `true` | Hour, minute, and seconds (`step=1`) | Seconds as picked; still `00` if the user only sets hour and minute |
+
+A form that omits `showSeconds` keeps the hour-and-minute picker and still submits a valid `HH:MM:SS` string.
diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx
new file mode 100644
index 0000000..0e40d18
--- /dev/null
+++ b/packages/jsonforms-renderers/src/renderers/DateControl.test.tsx
@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import { useState } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { JsonForms } from '@jsonforms/react'
+import { Theme } from '@radix-ui/themes'
+import type { JsonSchema, UISchemaElement } from '@jsonforms/core'
+import { radixRenderers } from './index'
+
+type Data = Record
+
+const finishers: (() => void)[] = []
+
+afterEach(() => {
+ for (const stop of finishers) stop()
+ finishers.length = 0
+ cleanup()
+})
+
+const openingTimeSchema: JsonSchema = {
+ type: 'object',
+ properties: {
+ openingTime: { type: 'string', format: 'time', title: 'Opening Time' },
+ },
+}
+
+const appointmentSchema: JsonSchema = {
+ type: 'object',
+ properties: {
+ appointment: { type: 'string', format: 'date-time', title: 'Appointment' },
+ },
+}
+
+function renderForm(schema: JsonSchema, uischema: UISchemaElement, seed: Data) {
+ const writes: Data[] = []
+ let live = true
+ finishers.push(() => {
+ live = false
+ })
+
+ function Harness() {
+ const [initial] = useState(seed)
+ return (
+
+ {
+ if (live) writes.push(data as Data)
+ }}
+ />
+
+ )
+ }
+
+ render()
+ return { writes }
+}
+
+const timeControl = {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/openingTime' }],
+} as UISchemaElement
+
+const secondsControl = {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/openingTime', options: { showSeconds: true } }],
+} as UISchemaElement
+
+const appointmentControl = {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/appointment' }],
+} as UISchemaElement
+
+const appointmentSecondsControl = {
+ type: 'VerticalLayout',
+ elements: [{ type: 'Control', scope: '#/properties/appointment', options: { showSeconds: true } }],
+} as UISchemaElement
+
+describe('DateControl time', () => {
+ it('stores HH:MM:SS when the picker emits HH:MM', async () => {
+ const { writes } = renderForm(openingTimeSchema, timeControl, {})
+ const input = screen.getByLabelText(/Opening Time/)
+
+ fireEvent.change(input, { target: { value: '14:30' } })
+
+ await waitFor(() => {
+ expect(writes.at(-1)).toEqual({ openingTime: '14:30:00' })
+ })
+ })
+
+ it('clears the time when the picker value is empty', async () => {
+ const { writes } = renderForm(openingTimeSchema, timeControl, { openingTime: '14:30:00' })
+ const input = screen.getByLabelText(/Opening Time/)
+
+ fireEvent.change(input, { target: { value: '' } })
+
+ await waitFor(() => {
+ expect(writes.at(-1)?.openingTime).toBeUndefined()
+ })
+ })
+
+ it('uses minute step unless showSeconds is set', () => {
+ renderForm(openingTimeSchema, timeControl, { openingTime: '14:30:00' })
+ expect(screen.getByLabelText(/Opening Time/).getAttribute('step')).toBe('60')
+ expect((screen.getByLabelText(/Opening Time/) as HTMLInputElement).value).toBe('14:30')
+ })
+
+ it('shows a seconds spinner when showSeconds is true', async () => {
+ const { writes } = renderForm(openingTimeSchema, secondsControl, {})
+ const input = screen.getByLabelText(/Opening Time/)
+
+ expect(input.getAttribute('step')).toBe('1')
+
+ fireEvent.change(input, { target: { value: '14:30' } })
+ await waitFor(() => {
+ expect(writes.at(-1)).toEqual({ openingTime: '14:30:00' })
+ })
+
+ fireEvent.change(input, { target: { value: '14:30:45' } })
+ await waitFor(() => {
+ expect(writes.at(-1)).toEqual({ openingTime: '14:30:45' })
+ })
+ })
+})
+
+describe('DateControl date-time', () => {
+ it('uses minute step on the time input unless showSeconds is set', () => {
+ renderForm(appointmentSchema, appointmentControl, { appointment: '2026-10-09T14:30:00' })
+ const timeInput = document.querySelector('input[type="time"]')
+ expect(timeInput?.getAttribute('step')).toBe('60')
+ expect((timeInput as HTMLInputElement).value).toBe('14:30')
+ })
+
+ it('shows a seconds spinner on date-time when showSeconds is true', () => {
+ renderForm(appointmentSchema, appointmentSecondsControl, { appointment: '2026-10-09T14:30:45' })
+ const timeInput = document.querySelector('input[type="time"]')
+ expect(timeInput?.getAttribute('step')).toBe('1')
+ expect((timeInput as HTMLInputElement).value).toBe('14:30:45')
+ })
+})
diff --git a/packages/jsonforms-renderers/src/renderers/DateControl.tsx b/packages/jsonforms-renderers/src/renderers/DateControl.tsx
index 4210cae..c685571 100644
--- a/packages/jsonforms-renderers/src/renderers/DateControl.tsx
+++ b/packages/jsonforms-renderers/src/renderers/DateControl.tsx
@@ -13,6 +13,7 @@ import { useEffect, type ReactNode } from 'react'
import dayjs from 'dayjs'
import { getErrorMessage } from '../utils/error'
+import { timeInputValue, toRfc3339Time } from '../utils/time'
// dayjs().format() defaults to RFC 3339 (e.g. 2026-06-05T12:30:00+05:30) with
// seconds + local offset, which ajv's strict "date-time" format check requires.
@@ -55,6 +56,7 @@ export const DateControl = ({
required,
errors,
schema,
+ uischema,
enabled,
visible = true,
}: ControlProps) => {
@@ -69,6 +71,10 @@ export const DateControl = ({
}
const isValid = errors.length === 0
const value: string = typeof data === 'string' ? data : ''
+ // Seconds spinner is opt-in. Omitted / false keeps hour+minute only.
+ // Stored values always include seconds so AJV format "time" still passes.
+ const showSeconds = uischema?.options?.showSeconds === true
+ const timeStep = showSeconds ? 1 : 60
const shell = (children: ReactNode) => (
@@ -81,8 +87,9 @@ export const DateControl = ({
return shell(
handleChange(path, e.target.value)}
+ step={timeStep}
+ value={timeInputValue(value, showSeconds)}
+ onChange={(e) => handleChange(path, toRfc3339Time(e.target.value))}
disabled={!enabled}
color={!isValid ? 'red' : undefined}
id={path}
@@ -93,9 +100,7 @@ export const DateControl = ({
// date and date-time share the same date input; date-time appends a native time input.
const hasTime = schema.format === 'date-time'
const [datePart = '', timeRaw = ''] = value.split('T')
- // The native only understands HH:MM(:SS); strip any
- // timezone suffix from the stored RFC 3339 value before feeding it back.
- const timeForInput = timeRaw.slice(0, 5)
+ const timeForInput = timeInputValue(timeRaw, showSeconds)
const commit = (nextDate: string, nextTime: string) => {
if (!nextDate) {
@@ -119,6 +124,7 @@ export const DateControl = ({
{hasTime && (
{
+ it('pads HH:MM with zero seconds', () => {
+ expect(toRfc3339Time('14:30')).toBe('14:30:00')
+ })
+
+ it('leaves HH:MM:SS unchanged', () => {
+ expect(toRfc3339Time('14:30:45')).toBe('14:30:45')
+ })
+
+ it('clears an empty picker value', () => {
+ expect(toRfc3339Time('')).toBeUndefined()
+ })
+})
+
+describe('timeInputValue', () => {
+ it('drops seconds when the seconds spinner is off', () => {
+ expect(timeInputValue('14:30:00', false)).toBe('14:30')
+ })
+
+ it('keeps seconds when the seconds spinner is on', () => {
+ expect(timeInputValue('14:30:45', true)).toBe('14:30:45')
+ })
+
+ it('fills missing seconds for the seconds spinner', () => {
+ expect(timeInputValue('14:30', true)).toBe('14:30:00')
+ })
+})
diff --git a/packages/jsonforms-renderers/src/utils/time.ts b/packages/jsonforms-renderers/src/utils/time.ts
new file mode 100644
index 0000000..cf1438b
--- /dev/null
+++ b/packages/jsonforms-renderers/src/utils/time.ts
@@ -0,0 +1,14 @@
+/** Normalize a native time input value for AJV `format: "time"` (RFC 3339 `HH:MM:SS`). */
+export const toRfc3339Time = (raw: string): string | undefined => {
+ if (!raw) return undefined
+ if (/^\d{2}:\d{2}$/.test(raw)) return `${raw}:00`
+ return raw
+}
+
+/** Format a stored time for a native ``, optionally hiding seconds. */
+export const timeInputValue = (stored: string, showSeconds: boolean): string => {
+ if (!stored) return ''
+ const match = stored.match(/^(\d{2}:\d{2})(:\d{2})?/)
+ if (!match) return stored
+ return showSeconds ? `${match[1]}${match[2] ?? ':00'}` : match[1]
+}