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 }" >
-
- +
+ + {{ validationErrorMessage }} @@ -266,6 +268,8 @@ watchEffect(() => { } .form-error-message.p-message.p-message-error { + position: fixed; + z-index: var(--z-index-error-banner); border-radius: 10px; background-color: var(--error-bg-color); border: 1px solid var(--error-text-color); @@ -275,7 +279,7 @@ watchEffect(() => { top: 1rem; :deep(.p-message-wrapper) { - padding: 0.75rem 0.75rem; + padding: 8px 15px; flex-grow: 1; } @@ -349,6 +353,7 @@ watchEffect(() => { margin: var(--wf-error-banner-gap) 1rem 0 1rem; max-width: unset; width: calc(100% - 2rem); + top: 0.27rem; } .questions-card { diff --git a/packages/web-forms/src/components/controls/Input/InputControl.vue b/packages/web-forms/src/components/controls/Input/InputControl.vue index 0c60996c9..6c60a37c0 100644 --- a/packages/web-forms/src/components/controls/Input/InputControl.vue +++ b/packages/web-forms/src/components/controls/Input/InputControl.vue @@ -1,4 +1,5 @@ + + + + diff --git a/packages/web-forms/src/components/controls/NoteControl.vue b/packages/web-forms/src/components/controls/NoteControl.vue index ec0efd07d..0d6ccaa15 100644 --- a/packages/web-forms/src/components/controls/NoteControl.vue +++ b/packages/web-forms/src/components/controls/NoteControl.vue @@ -1,7 +1,7 @@