diff --git a/.changeset/empty-views-stop.md b/.changeset/empty-views-stop.md
new file mode 100644
index 000000000..520bc5339
--- /dev/null
+++ b/.changeset/empty-views-stop.md
@@ -0,0 +1,8 @@
+---
+'@getodk/xforms-engine': minor
+'@getodk/web-forms': minor
+'@getodk/scenario': patch
+'@getodk/common': patch
+---
+
+Adds time question type
diff --git a/README.md b/README.md
index dd3d3ae8b..5bae9eb18 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
- ##### Question types (basic functionality)
π©π©π©π©π©π©π©π©π©π©π©π©β¬β¬β¬ 82\%
+ ##### Question types (basic functionality)
π©π©π©π©π©π©π©π©π©π©π©π©β¬β¬β¬ 85\%
@@ -63,7 +63,7 @@ This section is auto generated. Please update `feature-matrix.json` and then run
| video | β
|
| [file](https://github.com/getodk/web-forms/issues/370) | |
| date | β
|
-| time | |
+| time | β
|
| datetime | |
| rank | β
|
| csv-external | β
|
diff --git a/feature-matrix.json b/feature-matrix.json
index adc6dcacc..5fccb770d 100644
--- a/feature-matrix.json
+++ b/feature-matrix.json
@@ -21,7 +21,7 @@
"video": "β
",
"[file](https://github.com/getodk/web-forms/issues/370)": "",
"date": "β
",
- "time": "",
+ "time": "β
",
"datetime": "",
"rank": "β
",
"csv-external": "β
",
diff --git a/packages/common/src/constants/datetime.ts b/packages/common/src/constants/datetime.ts
index 79d231433..4c604aaa4 100644
--- a/packages/common/src/constants/datetime.ts
+++ b/packages/common/src/constants/datetime.ts
@@ -6,12 +6,13 @@ const ISO_DATE_LIKE_SUBPATTERN = '\\d{4}-\\d{2}-\\d{2}';
export const ISO_DATE_LIKE_PATTERN = new RegExp(`^${ISO_DATE_LIKE_SUBPATTERN}(?=T|$)`);
-const ISO_TIME_LIKE_SUBPATTERN = `(${[
- '\\d{2}:\\d{2}:\\d{2}\\.\\d+',
- '\\d{2}:\\d{2}:\\d{2}',
- '\\d{2}:\\d{2}',
- '\\d{2}',
-].join('|')})`;
+const STRICT_TIME_FORMATS = ['\\d{2}:\\d{2}:\\d{2}\\.\\d+', '\\d{2}:\\d{2}:\\d{2}'];
+
+const STRICT_ISO_TIME_SUBPATTERN = `(${[...STRICT_TIME_FORMATS].join('|')})`;
+
+const ISO_TIME_LIKE_SUBPATTERN = `(${[...STRICT_TIME_FORMATS, '\\d{2}:\\d{2}', '\\d{2}'].join(
+ '|'
+)})`;
export const ISO_TIME_LIKE_PATTERN = new RegExp(`^${ISO_TIME_LIKE_SUBPATTERN}$`);
@@ -25,7 +26,7 @@ const ISO_OFFSET_SUBPATTERN = `(${TIMEZONE_OFFSET_SUBPATTERN}|Z)`;
* Validates a timezone offset (e.g., "+01:00", "-12:30") in the format Β±HH:MM.
* Ensures hours are between 00 and 14, and minutes are between 00 and 59, matching standard timezone offset ranges.
*/
-export const VALID_OFFSET_VALUE = new RegExp('^[+-]([0][0-9]|1[0-4]):([0-5][0-9])$');
+export const VALID_OFFSET_VALUE = new RegExp('^([+-]([0][0-9]|1[0-4]):([0-5][0-9])|Z)$', 'i');
export const ISO_DATE_TIME_LIKE_PATTERN = new RegExp(
[
@@ -50,3 +51,7 @@ export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
);
+
+export const ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN = new RegExp(
+ ['^', STRICT_ISO_TIME_SUBPATTERN, `(${ISO_OFFSET_SUBPATTERN})?`, '$'].join('')
+);
diff --git a/packages/common/src/fixtures/date/date.xml b/packages/common/src/fixtures/date-and-time/date-and-time.xml
similarity index 87%
rename from packages/common/src/fixtures/date/date.xml
rename to packages/common/src/fixtures/date-and-time/date-and-time.xml
index f1d443dfc..aa9509975 100644
--- a/packages/common/src/fixtures/date/date.xml
+++ b/packages/common/src/fixtures/date-and-time/date-and-time.xml
@@ -20,6 +20,9 @@
When was the last time you ate vegetables?
+
+ What time is your first meal?
+
@@ -34,6 +37,9 @@
Quand est-ce que vous avez mangé des légumes pour la dernière fois ?
+
+ Γ quelle heure prends-tu ton premier repas ?
+
@@ -43,6 +49,7 @@
+
@@ -55,6 +62,7 @@
relevant=" /data/dates/date_of_birth != """/>
+
@@ -72,6 +80,9 @@
+
+
+
diff --git a/packages/common/src/fixtures/notes/2-all-possible-notes.xml b/packages/common/src/fixtures/notes/2-all-possible-notes.xml
index 259022fb7..ad53ee438 100644
--- a/packages/common/src/fixtures/notes/2-all-possible-notes.xml
+++ b/packages/common/src/fixtures/notes/2-all-possible-notes.xml
@@ -20,6 +20,7 @@
3
2025-12-21T23:30:05
+ 22:02:10.562+06:00
38.253094215699576 21.756382658677467 0 150
37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0
37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0; 37.7749 -122.4194 0 0
@@ -42,6 +43,7 @@
+
@@ -87,6 +89,9 @@
+
+
+
diff --git a/packages/scenario/test/time-bind-type.test.ts b/packages/scenario/test/time-bind-type.test.ts
new file mode 100644
index 000000000..ab7c814fc
--- /dev/null
+++ b/packages/scenario/test/time-bind-type.test.ts
@@ -0,0 +1,135 @@
+import {
+ bind,
+ body,
+ head,
+ html,
+ input,
+ mainInstance,
+ model,
+ t,
+ title,
+} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
+import { assert, beforeEach, describe, expect, it } from 'vitest';
+import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
+import { ModelValueNodeAnswer } from '../src/answer/ModelValueNodeAnswer.ts';
+import { Scenario } from '../src/jr/Scenario.ts';
+
+describe('Time bind type', () => {
+ const formTitle = 'Time bind types';
+ const relevancePath = '/root/relevance-trigger';
+ const relevanceExpression = `${relevancePath} = 'yes'`;
+
+ const formDefinition = html(
+ head(
+ title(formTitle),
+ model(
+ mainInstance(
+ t(
+ 'root id="time-types"',
+ t('relevance-trigger', 'yes'),
+ t('model-only-time', '22:02:55.124'),
+ t('input-time', '14:02:10')
+ )
+ ),
+ bind('/root/model-only-time').type('time').relevant(relevanceExpression),
+ bind('/root/input-time').type('time').relevant(relevanceExpression)
+ )
+ ),
+ body(input(relevancePath), input('/root/input-time'))
+ );
+
+ let scenario: Scenario;
+ beforeEach(async () => {
+ scenario = await Scenario.init(formTitle, formDefinition);
+ });
+
+ describe('model-only values', () => {
+ const getModelAnswer = () => {
+ const answer = scenario.answerOf('/root/model-only-time');
+ assert(answer instanceof ModelValueNodeAnswer);
+ assert(answer.valueType === 'time');
+ return answer as ModelValueNodeAnswer<'time'>;
+ };
+
+ it('has correct static type', () => {
+ const answer = getModelAnswer();
+ expect(answer.value).toBeTypeOf('string');
+ });
+
+ it('has a time populated value', () => {
+ const answer = getModelAnswer();
+ expect(answer.value).to.equal('22:02:55.124');
+ });
+
+ it('has null as a blank value when not relevant', () => {
+ scenario.answer(relevancePath, 'no');
+ const answer = getModelAnswer();
+ expect(answer.value).toBeNull();
+ });
+ });
+
+ describe('inputs', () => {
+ const getInputAnswer = () => {
+ const answer = scenario.answerOf('/root/input-time');
+ assert(answer instanceof InputNodeAnswer);
+ assert(answer.valueType === 'time');
+ return answer as InputNodeAnswer<'time'>;
+ };
+
+ it('has correct static type', () => {
+ const answer = getInputAnswer();
+ expect(answer.value).toBeTypeOf('string');
+ });
+
+ it('has a time populated value', () => {
+ const answer = getInputAnswer();
+ expect(answer.value).to.equal('14:02:10');
+ expect(answer.stringValue).toEqual('14:02:10');
+ });
+
+ it('has null as a blank value when not relevant', () => {
+ scenario.answer(relevancePath, 'no');
+ const answer = getInputAnswer();
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+
+ it.each([
+ '22:02:00.000+07:00',
+ '22:02:00+07:00',
+ '22:02:00.000-07:00',
+ '22:02:00-07:00',
+ '22:02:00.000Z',
+ '22:02:00Z',
+ '22:02:00.000',
+ '22:02:00',
+ ])('sets value with valid time (%s)', (expression) => {
+ scenario.answer('/root/input-time', expression);
+ const answer = getInputAnswer();
+
+ expect(answer.value).to.deep.equal(expression);
+ expect(answer.stringValue).toEqual(expression);
+ });
+
+ it.each([
+ '25:02:00',
+ '24:00:00',
+ '22:60:00',
+ '22:02:61',
+ '22:02',
+ '22',
+ '22:02:00+25:00',
+ '22:02:00+invalid',
+ '22:02:00.Z', // Malformed decimal/Z combination
+ 'noon',
+ 'XX:YY:ZZ',
+ '2026-02-24T22:02:00',
+ ])('has null when incorrect value %s is passed', (expression) => {
+ scenario.answer('/root/input-time', expression);
+ const answer = getInputAnswer();
+
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+ });
+});
diff --git a/packages/web-forms/src/assets/styles/primevue.scss b/packages/web-forms/src/assets/styles/primevue.scss
new file mode 100644
index 000000000..1a5a75faa
--- /dev/null
+++ b/packages/web-forms/src/assets/styles/primevue.scss
@@ -0,0 +1,15 @@
+.odk-form {
+ .p-datepicker-input:disabled,
+ .p-datepicker-input:disabled + .p-datepicker-input-icon-container {
+ cursor: not-allowed;
+ }
+}
+
+// Modals and dropdowns are outside the .odk-form class scope.
+.p-datepicker-panel,
+.p-dialog {
+ .p-button.p-button-secondary:focus-visible {
+ outline: none;
+ outline-offset: unset;
+ }
+}
diff --git a/packages/web-forms/src/assets/styles/style.scss b/packages/web-forms/src/assets/styles/style.scss
index 4fdd05862..a7408e8ae 100644
--- a/packages/web-forms/src/assets/styles/style.scss
+++ b/packages/web-forms/src/assets/styles/style.scss
@@ -40,6 +40,7 @@
--odk-text-color: var(--p-surface-900);
--odk-inverted-text-color: var(--p-surface-0);
--odk-muted-text-color: var(--p-surface-500);
+ --odk-light-muted-text-color: var(--p-surface-400);
--odk-base-background-color: var(--p-surface-0);
--odk-light-background-color: var(--p-surface-50);
diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue
index 643c348b2..b8e452ac8 100644
--- a/packages/web-forms/src/components/OdkWebForm.vue
+++ b/packages/web-forms/src/components/OdkWebForm.vue
@@ -526,6 +526,7 @@ onUnmounted(() => {
diff --git a/packages/web-forms/src/components/form-elements/input/InputControl.vue b/packages/web-forms/src/components/form-elements/input/InputControl.vue
index 85d0b362c..1e11ae07e 100644
--- a/packages/web-forms/src/components/form-elements/input/InputControl.vue
+++ b/packages/web-forms/src/components/form-elements/input/InputControl.vue
@@ -10,6 +10,7 @@ import InputGeoMultiPoint from '@/components/form-elements/input/InputGeoMultiPo
import InputInt from '@/components/form-elements/input/InputInt.vue';
import InputNumbersAppearance from '@/components/form-elements/input/InputNumbersAppearance.vue';
import InputText from '@/components/form-elements/input/InputText.vue';
+import InputTime from '@/components/form-elements/input/InputTime.vue';
import { IS_FORM_EDIT_MODE } from '@/lib/constants/injection-keys.ts';
import type { AnyInputNode } from '@getodk/xforms-engine';
import { inject } from 'vue';
@@ -49,6 +50,9 @@ const isFormEditMode = inject(IS_FORM_EDIT_MODE);
+
+
+
diff --git a/packages/web-forms/src/components/form-elements/input/InputDate.vue b/packages/web-forms/src/components/form-elements/input/InputDate.vue
index ceb4ea3b1..e2ab19162 100644
--- a/packages/web-forms/src/components/form-elements/input/InputDate.vue
+++ b/packages/web-forms/src/components/form-elements/input/InputDate.vue
@@ -41,11 +41,6 @@ const isDisabled = computed(() => props.question.currentState.readonly === true)
font-size: var(--odk-hint-font-size);
}
-.p-datepicker-input:disabled,
-.p-datepicker-input:disabled + .p-datepicker-input-icon-container {
- cursor: not-allowed;
-}
-
.p-datepicker .p-datepicker-input-icon-container {
top: 19px;
diff --git a/packages/web-forms/src/components/form-elements/input/InputTime.vue b/packages/web-forms/src/components/form-elements/input/InputTime.vue
new file mode 100644
index 000000000..207bd1b7b
--- /dev/null
+++ b/packages/web-forms/src/components/form-elements/input/InputTime.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/xforms-engine/src/client/InputNode.ts b/packages/xforms-engine/src/client/InputNode.ts
index f665d948c..1205402d8 100644
--- a/packages/xforms-engine/src/client/InputNode.ts
+++ b/packages/xforms-engine/src/client/InputNode.ts
@@ -85,6 +85,7 @@ export type StringInputValue = InputValue<'string'>;
export type IntInputValue = InputValue<'int'>;
export type DecimalInputValue = InputValue<'decimal'>;
export type DateInputValue = InputValue<'date'>;
+export type TimeInputValue = InputValue<'time'>;
export type GeopointInputValue = InputValue<'geopoint'>;
export type GeoshapeInputValue = InputValue<'geoshape'>;
export type GeotraceInputValue = InputValue<'geotrace'>;
@@ -93,6 +94,7 @@ export type StringInputNode = InputNode<'string'>;
export type IntInputNode = InputNode<'int'>;
export type DecimalInputNode = InputNode<'decimal'>;
export type DateInputNode = InputNode<'date'>;
+export type TimeInputNode = InputNode<'time'>;
export type GeopointInputNode = InputNode<'geopoint'>;
export type GeoshapeInputNode = InputNode<'geoshape'>;
export type GeotraceInputNode = InputNode<'geotrace'>;
@@ -104,6 +106,7 @@ type SupportedInputValueType =
| 'int'
| 'decimal'
| 'date'
+ | 'time'
| 'geopoint'
| 'geoshape'
| 'geotrace';
@@ -119,6 +122,7 @@ export type AnyInputNode =
| IntInputNode
| DecimalInputNode
| DateInputNode
+ | TimeInputNode
| GeopointInputNode
| GeoshapeInputNode
| GeotraceInputNode
diff --git a/packages/xforms-engine/src/client/NoteNode.ts b/packages/xforms-engine/src/client/NoteNode.ts
index 0a2ee41b0..147e1b931 100644
--- a/packages/xforms-engine/src/client/NoteNode.ts
+++ b/packages/xforms-engine/src/client/NoteNode.ts
@@ -82,6 +82,7 @@ export type StringNoteValue = NoteValue<'string'>;
export type IntNoteValue = NoteValue<'int'>;
export type DecimalNoteValue = NoteValue<'decimal'>;
export type DateNoteValue = NoteValue<'date'>;
+export type TimeNoteValue = NoteValue<'time'>;
export type GeopointNoteValue = NoteValue<'geopoint'>;
export type GeoshapeNoteValue = NoteValue<'geoshape'>;
export type GeotraceNoteValue = NoteValue<'geotrace'>;
@@ -90,6 +91,7 @@ export type StringNoteNode = NoteNode<'string'>;
export type IntNoteNode = NoteNode<'int'>;
export type DecimalNoteNode = NoteNode<'decimal'>;
export type DateNoteNode = NoteNode<'date'>;
+export type TimeNoteNode = NoteNode<'time'>;
export type GeopointNoteNode = NoteNode<'geopoint'>;
export type GeoshapeNoteNode = NoteNode<'geoshape'>;
export type GeotraceNoteNode = NoteNode<'geotrace'>;
@@ -101,6 +103,7 @@ type SupportedNoteValueType =
| 'int'
| 'decimal'
| 'date'
+ | 'time'
| 'geopoint'
| 'geoshape'
| 'geotrace';
@@ -116,6 +119,7 @@ export type AnyNoteNode =
| IntNoteNode
| DecimalNoteNode
| DateNoteNode
+ | TimeNoteNode
| GeopointNoteNode
| GeoshapeNoteNode
| GeotraceNoteNode
diff --git a/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts b/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts
new file mode 100644
index 000000000..9189f2f4c
--- /dev/null
+++ b/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts
@@ -0,0 +1,97 @@
+import {
+ ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN,
+ VALID_OFFSET_VALUE,
+} from '@getodk/common/constants/datetime.ts';
+import { Temporal } from 'temporal-polyfill';
+import { type CodecDecoder, type CodecEncoder, ValueCodec } from './ValueCodec.ts';
+
+export type TimeRuntimeValue = string | null;
+
+export type TimeInputValue =
+ | Date
+ | Temporal.PlainDateTime
+ | Temporal.PlainTime
+ | Temporal.ZonedDateTime
+ | string
+ | null;
+
+const validateTimeString = (value: string): TimeRuntimeValue => {
+ const match = ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN.exec(value ?? '');
+ if (!match?.length) {
+ return null;
+ }
+
+ const [, timeOnly = '', offset] = match;
+ try {
+ // Delegate bounds checking to Temporal
+ Temporal.PlainTime.from(timeOnly);
+ } catch {
+ return null;
+ }
+
+ return offset && !VALID_OFFSET_VALUE.test(offset) ? null : value;
+};
+
+const parseZonedDateTimeToString = (value: Temporal.ZonedDateTime): string => {
+ if (!value) {
+ return '';
+ }
+
+ const MILLISECONDS = 3;
+ const time = value.toPlainTime().toString({ fractionalSecondDigits: MILLISECONDS });
+ return `${time}${value.offset}`;
+};
+
+/**
+ * Converts a time-like value ({@link TimeInputValue}) to a strict time string.
+ * Honors timezones/offsets if present on the input objects.
+ *
+ * @param value - The value to convert.
+ * @returns A time string or empty string if invalid.
+ */
+const toTimeString = (value: TimeInputValue): string => {
+ if (value == null) {
+ return '';
+ }
+
+ try {
+ if (value instanceof Date) {
+ const zonedTime = Temporal.Instant.fromEpochMilliseconds(value.getTime()).toZonedDateTimeISO(
+ Temporal.Now.timeZoneId()
+ );
+ return parseZonedDateTimeToString(zonedTime);
+ }
+
+ if (value instanceof Temporal.PlainTime) {
+ return value.toString();
+ }
+
+ if (value instanceof Temporal.PlainDateTime) {
+ return value.toPlainTime().toString();
+ }
+
+ if (value instanceof Temporal.ZonedDateTime) {
+ return parseZonedDateTimeToString(value);
+ }
+
+ return validateTimeString(value) ?? '';
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn('Error parsing time value:', error);
+ return '';
+ }
+};
+
+export class TimeValueCodec extends ValueCodec<'time', TimeRuntimeValue, TimeInputValue> {
+ constructor() {
+ const encodeValue: CodecEncoder = (value) => {
+ return toTimeString(value);
+ };
+
+ const decodeValue: CodecDecoder = (value: string) => {
+ return validateTimeString(value);
+ };
+
+ super('time', encodeValue, decodeValue);
+ }
+}
diff --git a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
index 02fb74321..e62019677 100644
--- a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
+++ b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts
@@ -24,6 +24,7 @@ import {
} from './geolocation/Geotrace.ts';
import { type IntInputValue, type IntRuntimeValue, IntValueCodec } from './IntValueCodec.ts';
import { StringValueCodec } from './StringValueCodec.ts';
+import { type TimeInputValue, type TimeRuntimeValue, TimeValueCodec } from './TimeValueCodec.ts';
import type { ValueCodec } from './ValueCodec.ts';
import { ValueTypePlaceholderCodec } from './ValueTypePlaceholderCodec.ts';
@@ -33,7 +34,7 @@ interface RuntimeValuesByType {
readonly decimal: DecimalRuntimeValue;
readonly boolean: string;
readonly date: DatetimeRuntimeValue;
- readonly time: string;
+ readonly time: TimeRuntimeValue;
readonly dateTime: string;
readonly geopoint: GeopointRuntimeValue;
readonly geotrace: GeotraceRuntimeValue;
@@ -51,7 +52,7 @@ interface RuntimeInputValuesByType {
readonly decimal: DecimalInputValue;
readonly boolean: string;
readonly date: DatetimeInputValue;
- readonly time: string;
+ readonly time: TimeInputValue;
readonly dateTime: string;
readonly geopoint: GeopointInputValue;
readonly geotrace: GeotraceInputValue;
@@ -80,7 +81,7 @@ export const sharedValueCodecs: SharedValueCodecs = {
decimal: new DecimalValueCodec(),
boolean: new ValueTypePlaceholderCodec('boolean'),
date: new DateValueCodec(),
- time: new ValueTypePlaceholderCodec('time'),
+ time: new TimeValueCodec(),
dateTime: new ValueTypePlaceholderCodec('dateTime'),
binary: new ValueTypePlaceholderCodec('binary'),
barcode: new ValueTypePlaceholderCodec('barcode'),