diff --git a/.changeset/neat-numbers-change.md b/.changeset/neat-numbers-change.md
new file mode 100644
index 000000000..4a6cbaeb4
--- /dev/null
+++ b/.changeset/neat-numbers-change.md
@@ -0,0 +1,10 @@
+---
+'@getodk/xforms-engine': minor
+'@getodk/web-forms': minor
+'@getodk/scenario': patch
+'@getodk/common': minor
+'@getodk/xpath': minor
+---
+
+- Support for date questions with no appearance
+- Support for date notes
diff --git a/packages/xpath/src/lib/datetime/constants.ts b/packages/common/src/constants/datetime.ts
similarity index 85%
rename from packages/xpath/src/lib/datetime/constants.ts
rename to packages/common/src/constants/datetime.ts
index a68628483..7a265cd11 100644
--- a/packages/xpath/src/lib/datetime/constants.ts
+++ b/packages/common/src/constants/datetime.ts
@@ -35,3 +35,7 @@ export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
'$',
].join('')
);
+
+export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
+ ['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
+);
diff --git a/packages/common/src/fixtures/date/date.xml b/packages/common/src/fixtures/date/date.xml
new file mode 100644
index 000000000..f1d443dfc
--- /dev/null
+++ b/packages/common/src/fixtures/date/date.xml
@@ -0,0 +1,77 @@
+
+
+
+ Date
+
+
+
+
+ When are you filling out this survey?
+
+
+ When were you born?
+
+
+ When was the last time you ate fruits?
+
+
+ When was the last time you ate vegetables?
+
+
+
+
+ Quand allez-vous remplir ce questionnaire?
+
+
+ Quand êtes-vous né(e) ?
+
+
+ Quand est-ce que vous avez mangé des fruits pour la dernière fois ?
+
+
+ Quand est-ce que vous avez mangé des légumes pour la dernière fois ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 cd64803df..8489ce33e 100644
--- a/packages/common/src/fixtures/notes/2-all-possible-notes.xml
+++ b/packages/common/src/fixtures/notes/2-all-possible-notes.xml
@@ -19,7 +19,8 @@
3
- 38.253094215699576 21.756382658677467 0 150
+ 2025-12-21T23:30:05
+ 38.253094215699576 21.756382658677467 0 150
@@ -38,8 +39,9 @@
+
+
-
@@ -78,7 +80,10 @@
-
+
+
+
+
diff --git a/packages/scenario/package.json b/packages/scenario/package.json
index 083b764d0..9e05c5492 100644
--- a/packages/scenario/package.json
+++ b/packages/scenario/package.json
@@ -54,5 +54,8 @@
"solid-js": "^1.9.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
+ },
+ "dependencies": {
+ "temporal-polyfill": "^0.2.5"
}
}
diff --git a/packages/scenario/test/bind-types.test.ts b/packages/scenario/test/bind-types.test.ts
index cf6cf6c7d..a2345568f 100644
--- a/packages/scenario/test/bind-types.test.ts
+++ b/packages/scenario/test/bind-types.test.ts
@@ -11,6 +11,7 @@ import {
title,
} from '@getodk/common/test/fixtures/xform-dsl/index.ts';
import type { ValueType } from '@getodk/xforms-engine';
+import { Temporal } from 'temporal-polyfill';
import { assert, beforeEach, describe, expect, expectTypeOf, it } from 'vitest';
import { intAnswer } from '../src/answer/ExpectedIntAnswer.ts';
import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
@@ -38,13 +39,15 @@ describe('Data () type support', () => {
t('int-value', '123'),
t('decimal-value', '45.67'),
t('geopoint-value', '38.25146813817506 21.758421137528785 0 0'),
+ t('date-value', '1999-11-23T23:30:05'),
)
),
bind('/root/string-value').type('string').relevant(modelNodeRelevanceExpression),
bind('/root/implicit-string-value').relevant(modelNodeRelevanceExpression),
bind('/root/int-value').type('int').relevant(modelNodeRelevanceExpression),
bind('/root/decimal-value').type('decimal').relevant(modelNodeRelevanceExpression),
- bind('/root/geopoint-value').type('geopoint').relevant(modelNodeRelevanceExpression)
+ bind('/root/geopoint-value').type('geopoint').relevant(modelNodeRelevanceExpression),
+ bind('/root/date-value').type('date').relevant(modelNodeRelevanceExpression)
)
),
body(
@@ -211,12 +214,34 @@ describe('Data () type support', () => {
});
});
- it('has an null as blank value', () => {
+ it('has a null as blank value', () => {
scenario.answer(modelNodeRelevancePath, 'no');
answer = getTypedModelValueNodeAnswer('/root/geopoint-value', 'geopoint');
expect(answer.value).toBeNull();
});
});
+
+ describe('type="date"', () => {
+ let answer: ModelValueNodeAnswer<'date'>;
+
+ beforeEach(() => {
+ answer = getTypedModelValueNodeAnswer('/root/date-value', 'date');
+ });
+
+ it('has a PlainDate | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a date populated value', () => {
+ expect(answer.value).to.deep.equal(Temporal.PlainDate.from('1999-11-23'));
+ });
+
+ it('has a null as blank value', () => {
+ scenario.answer(modelNodeRelevancePath, 'no');
+ answer = getTypedModelValueNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).toBeNull();
+ });
+ });
});
describe('inputs', () => {
@@ -238,13 +263,15 @@ describe('Data () type support', () => {
t('int-value', '123'),
t('decimal-value', '45.67'),
t('geopoint-value', '38.25146813817506 21.758421137528785 1000 25'),
+ t('date-value', '2025-12-20'),
)
),
bind('/root/string-value').type('string').relevant(inputRelevanceExpression),
bind('/root/implicit-string-value').relevant(inputRelevanceExpression),
bind('/root/int-value').type('int').relevant(inputRelevanceExpression),
bind('/root/decimal-value').type('decimal').relevant(inputRelevanceExpression),
- bind('/root/geopoint-value').type('geopoint').relevant(inputRelevanceExpression)
+ bind('/root/geopoint-value').type('geopoint').relevant(inputRelevanceExpression),
+ bind('/root/date-value').type('date').relevant(inputRelevanceExpression)
)
),
body(
@@ -254,6 +281,7 @@ describe('Data () type support', () => {
input('/root/int-value'),
input('/root/decimal-value'),
input('/root/geopoint-value'),
+ input('/root/date-value'),
)
);
@@ -616,6 +644,62 @@ describe('Data () type support', () => {
}
);
});
+
+ describe('type="date"', () => {
+ let answer: InputNodeAnswer<'date'>;
+
+ beforeEach(() => {
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ });
+
+ it('has a PlainDate | null static type', () => {
+ expectTypeOf(answer.value).toEqualTypeOf();
+ });
+
+ it('has a date populated value', () => {
+ expect(answer.value).to.deep.equal(Temporal.PlainDate.from('2025-12-20'));
+ expect(answer.stringValue).toEqual('2025-12-20');
+ });
+
+ it('has an null as blank value', () => {
+ scenario.answer(inputRelevancePath, 'no');
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+
+ it.each([
+ '13:30:55',
+ '2025-23-23',
+ 'ZYX',
+ '2025-03-07T14:30:00+invalid',
+ '2025-03-07T14:30:00-08:00',
+ '2025-03-07T14:30:00Z',
+ ])('has null when incorrect value is passed', (expression) => {
+ scenario.answer('/root/date-value', expression);
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).toBeNull();
+ expect(answer.stringValue).toBe('');
+ });
+
+ it.each([
+ {
+ expression: '2025-03-14',
+ expectedAsObject: Temporal.PlainDate.from('2025-03-14'),
+ expectedAsText: '2025-03-14',
+ },
+ {
+ expression: '2025-12-21T14:30:00',
+ expectedAsObject: Temporal.PlainDate.from('2025-12-21'),
+ expectedAsText: '2025-12-21',
+ },
+ ])('sets value with valid date', ({ expression, expectedAsObject, expectedAsText }) => {
+ scenario.answer('/root/date-value', expression);
+ answer = getTypedInputNodeAnswer('/root/date-value', 'date');
+ expect(answer.value).to.deep.equal(expectedAsObject);
+ expect(answer.stringValue).toEqual(expectedAsText);
+ });
+ });
});
describe('casting fractional values to int', () => {
diff --git a/packages/web-forms/README.md b/packages/web-forms/README.md
index 5f9ed3864..f1f66749b 100644
--- a/packages/web-forms/README.md
+++ b/packages/web-forms/README.md
@@ -49,6 +49,23 @@ It uses the [PrimeVue component library](https://primevue.org/).
We are using a customized version of the Material Light Indigo theme provided by PrimeVue.
+#### Z-Index Layering System
+
+This package uses a centralized `z-index` layering system to manage UI stacking order, defined in `src/assets/css/z-index.css`. Custom properties (e.g., `--z-index-error-banner`) ensure elements like floating error messages, form controls, and overlays stack correctly without overlap.
+
+- **Key Layers**:
+
+ - `--z-index-base: 0` (background)
+ - `--z-index-form-content: 10` (inputs, buttons)
+ - `--z-index-form-floating: 20` (highlights, tooltips)
+ - `--z-index-error-banner: 30` (floating errors)
+ - `--z-index-overlay: 100` (modals)
+ - `--z-index-topmost: 1000` (loaders, notifications)
+
+- **Usage**: Apply with `z-index: var(--z-index-error-banner);` on positioned elements (e.g., `position: absolute`).
+
+See [src/assets/css/z-index.css](src/assets/css/z-index.css) for full details.
+
### Icons
We use **Material Icons** using IcoMoon to select a subset of icons in order to minimize the size. The font files are located in [`./src/assets/fonts/`](./src/assets/fonts/), and the CSS is [`./src/assets/css/icomoon.css`](/src/assets/css/icomoon.css). Our IcoMoon definition is in the root directory of this package at [`./icomoon.json`](./icomoon.json).
diff --git a/packages/web-forms/src/assets/css/z-index.css b/packages/web-forms/src/assets/css/z-index.css
new file mode 100644
index 000000000..1bdc09346
--- /dev/null
+++ b/packages/web-forms/src/assets/css/z-index.css
@@ -0,0 +1,39 @@
+/**
+ * Z-Index Layering System
+ *
+ * This project uses a centralized z-index layering system to manage the stacking order of UI
+ * elements, preventing overlap issues and ensuring maintainability. CSS custom properties are
+ * defined with meaningful names and a logical sequence, making it easy to understand and adjust
+ * the stacking order.
+ *
+ * Purpose:
+ * - Avoid z-index conflicts by assigning predefined layers to UI components.
+ * - Provide clarity on stacking priorities for developers and maintainers.
+ * - Allow flexibility for future additions without restructuring existing values.
+ *
+ * Example:
+ * .error-banner-placeholder {
+ * z-index: var(--z-index-error-banner);
+ * position: absolute;
+ * }
+ */
+
+:root {
+ /* Base layer for static content (e.g., page content, form background) */
+ --z-index-base: 0;
+
+ /* Standard form controls (e.g., inputs, labels, buttons) */
+ --z-index-form-content: 10;
+
+ /* Floating form elements (e.g., validation highlights, tooltips) */
+ --z-index-form-floating: 20;
+
+ /* Floating error messages or banners above the form */
+ --z-index-error-banner: 30;
+
+ /* Overlays like modals, popups, or dialogs above form content */
+ --z-index-overlay: 100;
+
+ /* Critical UI elements (e.g., loading spinners, toast notifications) */
+ --z-index-topmost: 1000;
+}
diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue
index e06d01d2a..40cd33b22 100644
--- a/packages/web-forms/src/components/OdkWebForm.vue
+++ b/packages/web-forms/src/components/OdkWebForm.vue
@@ -8,7 +8,6 @@ import { initializeForm, type FetchFormAttachment, type RootNode } from '@getodk
import Button from 'primevue/button';
import Card from 'primevue/card';
import PrimeMessage from 'primevue/message';
-import type { ComponentPublicInstance } from 'vue';
import { computed, getCurrentInstance, provide, reactive, ref, watchEffect } from 'vue';
import { FormInitializationError } from '../lib/error/FormInitializationError.ts';
import FormLoadFailureDialog from './Form/FormLoadFailureDialog.vue';
@@ -105,6 +104,8 @@ const emit = defineEmits();
const odkForm = ref();
const submitPressed = ref(false);
+const floatingErrorActive = ref(false);
+const showValidationError = ref(false);
const initializeFormError = ref();
initializeForm(props.formXml, {
@@ -125,18 +126,18 @@ const handleSubmit = () => {
const root = odkForm.value;
if (root?.validationState.violations?.length === 0) {
+ floatingErrorActive.value = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
emitSubmit(root);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
emitSubmitChunked(root);
} else {
+ floatingErrorActive.value = true;
submitPressed.value = true;
document.scrollingElement?.scrollTo(0, 0);
}
};
-const validationErrorMessagePopover = ref(null);
-
provide('submitPressed', submitPressed);
const validationErrorMessage = computed(() => {
@@ -149,10 +150,10 @@ const validationErrorMessage = computed(() => {
});
watchEffect(() => {
- if (submitPressed.value && validationErrorMessage.value) {
- (validationErrorMessagePopover.value?.$el as HTMLElement)?.showPopover?.();
+ if (floatingErrorActive.value && validationErrorMessage.value?.length) {
+ showValidationError.value = true;
} else {
- (validationErrorMessagePopover.value?.$el as HTMLElement)?.hidePopover?.();
+ showValidationError.value = false;
}
});
@@ -186,8 +187,9 @@ watchEffect(() => {
:class="{ 'submit-pressed': submitPressed }"
>