From d60b76e828323163978d9f3b3005afbf9ffba04f Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 23 Jun 2026 13:57:43 +0200 Subject: [PATCH 1/3] fix(formplayer): Avoid changing user input while user is inputting numbers --- formulus-formplayer/AGENTS.md | 1 + .../src/hooks/useNumericDraftInput.test.ts | 78 ++++++++ .../src/hooks/useNumericDraftInput.ts | 161 ++++++++++++++++ .../renderers/NumberStepperRenderer.test.tsx | 179 ++++++++++++++++++ .../src/renderers/NumberStepperRenderer.tsx | 92 +++++---- .../src/stories/JsonFormsControlWrapper.tsx | 28 ++- .../stories/NumberStepperRenderer.stories.tsx | 121 ++++++++++++ .../src/types/CustomQuestionTypeContract.ts | 9 +- 8 files changed, 619 insertions(+), 50 deletions(-) create mode 100644 formulus-formplayer/src/hooks/useNumericDraftInput.test.ts create mode 100644 formulus-formplayer/src/hooks/useNumericDraftInput.ts create mode 100644 formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index 30634756f..ce0790119 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -58,6 +58,7 @@ This file gives AI assistants and developers enough context to work effectively 3. **Custom question types**: Loaded from a **manifest** (source strings) from the RN app, evaluated in a sandbox with `React` and `MaterialUI` on `window`. They use **format** in the schema (e.g. `"format": "signature"`), not only `type`. Contract: `src/types/CustomQuestionTypeContract.ts`. 4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens. 5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview. +6. **Numeric inputs** (`type: integer` / `type: number`): Built-in control is **`NumberStepperRenderer`** (`src/renderers/NumberStepperRenderer.tsx`) backed by **`useNumericDraftInput`** (`src/hooks/useNumericDraftInput.ts`). Policy: **draft text while focused** (`type="text"` + `inputMode` + `enterKeyHint` from `FormContext`); observation JSON stores **JSON numbers only** (parse on commit, never strings); **never clamp** to `minimum`/`maximum` while typing—surface AJV errors instead. Custom numeric question types must follow the same contract (see `CustomQuestionTypeContract.ts`). ## Adding or changing behavior diff --git a/formulus-formplayer/src/hooks/useNumericDraftInput.test.ts b/formulus-formplayer/src/hooks/useNumericDraftInput.test.ts new file mode 100644 index 000000000..5a232a0d6 --- /dev/null +++ b/formulus-formplayer/src/hooks/useNumericDraftInput.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + formatNumericDisplay, + parseNumericDraft, +} from './useNumericDraftInput'; + +describe('formatNumericDisplay', () => { + it('formats numbers and empty values', () => { + expect(formatNumericDisplay(16)).toBe('16'); + expect(formatNumericDisplay(12.5)).toBe('12.5'); + expect(formatNumericDisplay(undefined)).toBe(''); + expect(formatNumericDisplay(null)).toBe(''); + expect(formatNumericDisplay('')).toBe(''); + }); +}); + +describe('parseNumericDraft', () => { + describe('integer', () => { + it('parses complete integers without clamping to bounds', () => { + expect(parseNumericDraft('35', 'integer')).toEqual({ + kind: 'complete', + value: 35, + }); + expect(parseNumericDraft('0', 'integer')).toEqual({ + kind: 'complete', + value: 0, + }); + expect(parseNumericDraft('-3', 'integer')).toEqual({ + kind: 'complete', + value: -3, + }); + }); + + it('returns empty for blank input', () => { + expect(parseNumericDraft('', 'integer')).toEqual({ kind: 'empty' }); + expect(parseNumericDraft(' ', 'integer')).toEqual({ kind: 'empty' }); + }); + + it('returns incomplete for partial integer entry', () => { + expect(parseNumericDraft('-', 'integer')).toEqual({ kind: 'incomplete' }); + expect(parseNumericDraft('1.', 'integer')).toEqual({ + kind: 'incomplete', + }); + }); + + it('commits non-integer decimals as float for AJV to reject', () => { + expect(parseNumericDraft('1.5', 'integer')).toEqual({ + kind: 'complete', + value: 1.5, + }); + }); + }); + + describe('number', () => { + it('parses decimals', () => { + expect(parseNumericDraft('12.5', 'number')).toEqual({ + kind: 'complete', + value: 12.5, + }); + expect(parseNumericDraft('.5', 'number')).toEqual({ + kind: 'complete', + value: 0.5, + }); + }); + + it('allows partial decimal entry', () => { + expect(parseNumericDraft('1.', 'number')).toEqual({ kind: 'incomplete' }); + expect(parseNumericDraft('-', 'number')).toEqual({ kind: 'incomplete' }); + }); + + it('parses integers as numbers', () => { + expect(parseNumericDraft('35', 'number')).toEqual({ + kind: 'complete', + value: 35, + }); + }); + }); +}); diff --git a/formulus-formplayer/src/hooks/useNumericDraftInput.ts b/formulus-formplayer/src/hooks/useNumericDraftInput.ts new file mode 100644 index 000000000..dff68a2d4 --- /dev/null +++ b/formulus-formplayer/src/hooks/useNumericDraftInput.ts @@ -0,0 +1,161 @@ +import { useCallback, useState } from 'react'; +import type { InputHTMLAttributes } from 'react'; +import type { KeyboardPrimaryEnterKeyHint } from '../utils/keyboardEnterKeyHint'; + +export type NumericSchemaKind = 'integer' | 'number'; + +export type ParseNumericResult = + | { kind: 'empty' } + | { kind: 'complete'; value: number } + | { kind: 'incomplete' }; + +/** Format committed observation data for display when the field is not focused. */ +export function formatNumericDisplay(data: unknown): string { + if (data === undefined || data === null || data === '') return ''; + if (typeof data === 'number' && !Number.isNaN(data)) return String(data); + return ''; +} + +/** + * Parse draft text from a numeric input. + * Never clamps to schema bounds — callers validate via AJV. + */ +export function parseNumericDraft( + text: string, + schemaKind: NumericSchemaKind, +): ParseNumericResult { + const trimmed = text.trim(); + if (trimmed === '') return { kind: 'empty' }; + + if (schemaKind === 'integer') { + if (trimmed === '-') return { kind: 'incomplete' }; + if (/^-?\d+$/.test(trimmed)) { + return { kind: 'complete', value: parseInt(trimmed, 10) }; + } + if (/^-?\d*\.$/.test(trimmed) || trimmed === '.') { + return { kind: 'incomplete' }; + } + // Non-integer decimal text (e.g. "1.5") — commit as float so AJV type:integer fails. + const asFloat = parseFloat(trimmed); + if (!Number.isNaN(asFloat) && /^-?(\d+\.\d+|\.\d+)$/.test(trimmed)) { + return { kind: 'complete', value: asFloat }; + } + return { kind: 'incomplete' }; + } + + if (trimmed === '-' || trimmed === '.' || trimmed === '-.') { + return { kind: 'incomplete' }; + } + if (/^-?\d+\.$/.test(trimmed)) { + return { kind: 'incomplete' }; + } + if (/^-?(\d+\.?\d*|\.\d+)$/.test(trimmed)) { + const value = parseFloat(trimmed); + if (!Number.isNaN(value)) { + return { kind: 'complete', value }; + } + } + return { kind: 'incomplete' }; +} + +export type UseNumericDraftInputOptions = { + data: unknown; + path: string; + handleChange: (path: string, value: unknown) => void; + schemaKind: NumericSchemaKind; + enterKeyHint?: KeyboardPrimaryEnterKeyHint; + enabled?: boolean; +}; + +export type UseNumericDraftInputResult = { + isFocused: boolean; + displayValue: string; + inputMode: 'numeric' | 'decimal'; + onFocus: () => void; + onBlur: () => void; + onChange: (event: React.ChangeEvent) => void; + /** Sync draft text after a programmatic value change (e.g. stepper +/-). */ + syncDraftFromData: (value: unknown) => void; + inputProps: Pick< + InputHTMLAttributes, + 'type' | 'inputMode' | 'enterKeyHint' | 'autoComplete' + >; +}; + +export function useNumericDraftInput({ + data, + path, + handleChange, + schemaKind, + enterKeyHint, + enabled = true, +}: UseNumericDraftInputOptions): UseNumericDraftInputResult { + const [isFocused, setIsFocused] = useState(false); + const [draftText, setDraftText] = useState(null); + + const commitParsed = useCallback( + (text: string) => { + const parsed = parseNumericDraft(text, schemaKind); + if (parsed.kind === 'empty') { + handleChange(path, undefined); + } else if (parsed.kind === 'complete') { + handleChange(path, parsed.value); + } + }, + [handleChange, path, schemaKind], + ); + + const displayValue = + isFocused && draftText !== null + ? draftText + : formatNumericDisplay(data); + + const onFocus = useCallback(() => { + if (!enabled) return; + setIsFocused(true); + setDraftText(formatNumericDisplay(data)); + }, [data, enabled]); + + const onBlur = useCallback(() => { + if (draftText !== null) { + commitParsed(draftText); + } + setIsFocused(false); + setDraftText(null); + }, [commitParsed, draftText]); + + const onChange = useCallback( + (event: React.ChangeEvent) => { + const text = event.target.value; + setDraftText(text); + commitParsed(text); + }, + [commitParsed], + ); + + const syncDraftFromData = useCallback( + (value: unknown) => { + const formatted = formatNumericDisplay(value); + if (isFocused) { + setDraftText(formatted); + } + }, + [isFocused], + ); + + return { + isFocused, + displayValue, + inputMode: schemaKind === 'integer' ? 'numeric' : 'decimal', + onFocus, + onBlur, + onChange, + syncDraftFromData, + inputProps: { + type: 'text', + inputMode: schemaKind === 'integer' ? 'numeric' : 'decimal', + ...(enterKeyHint ? { enterKeyHint } : {}), + autoComplete: 'off', + }, + }; +} diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx new file mode 100644 index 000000000..f87ce74b1 --- /dev/null +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import { numberStepperRenderer } from './NumberStepperRenderer'; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +const hbSchema = { + type: 'object', + properties: { + hb_resultado: { + type: 'number', + title: 'Resultado de hemoglobina', + minimum: 0, + maximum: 25, + }, + }, +}; + +const hbUischema = { + type: 'Control', + scope: '#/properties/hb_resultado', +}; + +function NumericTestHarness({ + schema, + uischema, + initialData = {}, + validationMode = 'ValidateAndShow' as const, +}: { + schema: object; + uischema: object; + initialData?: Record; + validationMode?: 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation'; +}) { + const [data, setData] = useState>(initialData); + + return ( + + + setData(next || {})} + /> +
{JSON.stringify(data)}
+
+
+ ); +} + +afterEach(() => cleanup()); + +function readCommittedData(): Record { + return JSON.parse(screen.getByTestId('committed-data').textContent || '{}'); +} + +describe('NumberStepperRenderer', () => { + it('uses text input with decimal inputMode and enterKeyHint', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('type', 'text'); + expect(input).toHaveAttribute('inputmode', 'decimal'); + expect(input).toHaveAttribute('enterkeyhint', 'next'); + }); + + it('stores JSON numbers not strings when typing', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('textbox'); + await user.click(input); + fireEvent.change(input, { target: { value: '16' } }); + await user.tab(); + + await waitFor(() => { + const data = readCommittedData(); + expect(data.hb_resultado).toBe(16); + expect(typeof data.hb_resultado).toBe('number'); + }); + }); + + it('allows clearing an over-max value without forcing a bound digit', async () => { + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + await user.click(input); + expect(input.value).toBe('35'); + + fireEvent.change(input, { target: { value: '' } }); + expect(input.value).toBe(''); + await user.tab(); + + await waitFor(() => { + expect(readCommittedData().hb_resultado).toBeUndefined(); + }); + }); + + it('shows validation error for values above maximum while keeping typed value', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('textbox'); + await user.click(input); + await user.type(input, '35'); + + expect((input as HTMLInputElement).value).toBe('35'); + expect(screen.getByText(/must be <= 25/i)).toBeInTheDocument(); + await waitFor(() => { + expect(readCommittedData().hb_resultado).toBe(35); + }); + }); + + it('disables stepper plus at maximum without altering typed draft', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('textbox')); + + const addButton = screen.getByRole('button', { name: /increase/i }); + expect(addButton).toBeDisabled(); + }); + + it('commits decimal values for type number', async () => { + const user = userEvent.setup(); + const decimalSchema = { + type: 'object', + properties: { + value: { type: 'number', multipleOf: 0.1 }, + }, + }; + const decimalUi = { type: 'Control', scope: '#/properties/value' }; + + render( + , + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + fireEvent.change(input, { target: { value: '12.5' } }); + await user.tab(); + + await waitFor(() => { + expect(readCommittedData().value).toBe(12.5); + }); + expect(input).toHaveAttribute('inputmode', 'decimal'); + }); +}); diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx index 26b2be048..a0ffab3d1 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx @@ -3,9 +3,12 @@ * * Custom renderer for number/integer fields that adds simple +/- buttons * via Material-UI's InputAdornment. Uses QuestionShell for unified layout. + * + * Numeric input policy: draft text while focused; observation JSON stores + * numbers only; never clamp to schema bounds while typing. */ -import React, { useState } from 'react'; +import React from 'react'; import { ControlProps, RankedTester, @@ -17,6 +20,11 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { TextField, InputAdornment, IconButton } from '@mui/material'; import { Add, Remove } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useFormContext } from '../App'; +import { + useNumericDraftInput, + type NumericSchemaKind, +} from '../hooks/useNumericDraftInput'; const isNumberControl: RankedTester = rankWith( 5, @@ -26,6 +34,12 @@ const isNumberControl: RankedTester = rankWith( }), ); +function committedNumeric(data: unknown): number | undefined { + if (data === undefined || data === null || data === '') return undefined; + const n = typeof data === 'number' ? data : Number(data); + return Number.isNaN(n) ? undefined : n; +} + const NumberStepperRenderer = ({ data, handleChange, @@ -38,12 +52,29 @@ const NumberStepperRenderer = ({ visible, required, }: ControlProps) => { - const [isFocused, setIsFocused] = useState(false); + const { keyboardEnterKeyHint } = useFormContext(); + const schemaKind: NumericSchemaKind = + schema.type === 'integer' ? 'integer' : 'number'; + + const { + isFocused, + displayValue, + onFocus, + onBlur, + onChange, + syncDraftFromData, + inputProps, + } = useNumericDraftInput({ + data, + path, + handleChange, + schemaKind, + enterKeyHint: keyboardEnterKeyHint, + enabled, + }); if (visible === false) return null; - const numericValue = - data !== undefined && data !== null && data !== '' ? Number(data) : 0; const min = schema.minimum ?? (schema as { minimum?: number }).minimum; const max = schema.maximum ?? (schema as { maximum?: number }).maximum; const step = schema.multipleOf ?? (schema as { step?: number }).step ?? 1; @@ -59,36 +90,27 @@ const NumberStepperRenderer = ({ (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === true; + const currentValue = committedNumeric(data) ?? 0; + + const applyStepperValue = (newValue: number) => { + handleChange(path, newValue); + syncDraftFromData(newValue); + }; + const handleAdd = () => { - const currentValue = numericValue || 0; const newValue = currentValue + step; if (max === undefined || newValue <= max) { - handleChange(path, newValue); + applyStepperValue(newValue); } }; const handleSubtract = () => { - const currentValue = numericValue || 0; const newValue = currentValue - step; if (min === undefined || newValue >= min) { - handleChange(path, newValue); - } - }; - - const handleInputChange = (event: React.ChangeEvent) => { - const value = event.target.value; - if (value === '') { - handleChange(path, undefined); - return; - } - const numValue = Number(value); - if (!isNaN(numValue)) { - // Do not clamp while typing — validation surfaces out-of-range values. - handleChange(path, numValue); + applyStepperValue(newValue); } }; - const currentValue = numericValue || 0; const addDisabled = max !== undefined && currentValue >= max; const subtractDisabled = min !== undefined && currentValue <= min; @@ -103,17 +125,16 @@ const NumberStepperRenderer = ({ required={isRequired} error={errorStr}> setIsFocused(true)} - onBlur={() => setIsFocused(false)} + value={displayValue} + onChange={onChange} + onFocus={onFocus} + onBlur={onBlur} disabled={!enabled} error={Boolean(errorStr)} fullWidth autoFocus={autoFocus} inputProps={{ - step, + ...inputProps, ...(autoFocus ? { 'data-formplayer-autofocus': 'true' } : {}), }} InputProps={{ @@ -140,20 +161,7 @@ const NumberStepperRenderer = ({ ) : null, }} - sx={{ - width: '100%', - '& input[type="number"]': { - MozAppearance: 'textfield', - '&::-webkit-outer-spin-button': { - WebkitAppearance: 'none', - margin: 0, - }, - '&::-webkit-inner-spin-button': { - WebkitAppearance: 'none', - margin: 0, - }, - }, - }} + sx={{ width: '100%' }} /> ); diff --git a/formulus-formplayer/src/stories/JsonFormsControlWrapper.tsx b/formulus-formplayer/src/stories/JsonFormsControlWrapper.tsx index e41fc26f9..3923b8744 100644 --- a/formulus-formplayer/src/stories/JsonFormsControlWrapper.tsx +++ b/formulus-formplayer/src/stories/JsonFormsControlWrapper.tsx @@ -2,12 +2,16 @@ import React, { useState } from 'react'; import { JsonForms } from '@jsonforms/react'; import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core'; +import { FormContext } from '../App'; +import type { KeyboardPrimaryEnterKeyHint } from '../utils/keyboardEnterKeyHint'; interface JsonFormsControlWrapperProps { schema: JsonSchema7; uischema: UISchemaElement; initialData?: Record; renderers: JsonFormsRendererRegistryEntry[]; + keyboardEnterKeyHint?: KeyboardPrimaryEnterKeyHint; + validationMode?: 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation'; } /** @@ -19,16 +23,26 @@ export function JsonFormsControlWrapper({ uischema, initialData = {}, renderers, + keyboardEnterKeyHint = 'next', + validationMode = 'ValidateAndShow', }: JsonFormsControlWrapperProps) { const [data, setData] = useState>(initialData); return ( - setData(newData || {})} - /> + + setData(newData || {})} + /> + ); } diff --git a/formulus-formplayer/src/stories/NumberStepperRenderer.stories.tsx b/formulus-formplayer/src/stories/NumberStepperRenderer.stories.tsx index 93239383f..3f440903e 100644 --- a/formulus-formplayer/src/stories/NumberStepperRenderer.stories.tsx +++ b/formulus-formplayer/src/stories/NumberStepperRenderer.stories.tsx @@ -22,6 +22,40 @@ const ageUischema = { scope: '#/properties/age', }; +const hemoglobinSchema = { + type: 'object', + properties: { + hb_resultado: { + type: 'number', + title: 'Resultado de hemoglobina', + minimum: 0, + maximum: 25, + }, + }, +}; + +const hemoglobinUischema = { + type: 'Control', + scope: '#/properties/hb_resultado', + label: 'Resultado de hemoglobina', +}; + +const decimalSchema = { + type: 'object', + properties: { + measurement: { + type: 'number', + title: 'Measurement', + multipleOf: 0.1, + }, + }, +}; + +const decimalUischema = { + type: 'Control', + scope: '#/properties/measurement', +}; + const renderers = [numberStepperRenderer, ...materialRenderers]; const meta: Meta = { @@ -29,12 +63,22 @@ const meta: Meta = { component: JsonFormsControlWrapper, parameters: { layout: 'centered', + docs: { + description: { + component: + 'Built-in numeric control for `type: integer` and `type: number`. Uses draft-while-focused text input (`inputMode` + `enterKeyHint`); never clamps schema bounds while typing.', + }, + }, }, tags: ['autodocs'], argTypes: { initialData: { description: 'Initial value for the number field', }, + keyboardEnterKeyHint: { + control: 'select', + options: ['next', 'done', 'go'], + }, }, }; @@ -48,6 +92,7 @@ export const Default: Story = { uischema: ageUischema, initialData: {}, renderers, + keyboardEnterKeyHint: 'next', }, }; @@ -77,3 +122,79 @@ export const AtMaximum: Story = { renderers, }, }; + +/** Mirrors GBMIS lab `hb_resultado` — decimal `type: number` with bounds 0–25. */ +export const HemoglobinLab: Story = { + args: { + schema: hemoglobinSchema, + uischema: hemoglobinUischema, + initialData: {}, + renderers, + keyboardEnterKeyHint: 'next', + }, +}; + +/** Type 35 to see max validation without value snapping; backspace to clear and enter 16. */ +export const OverMaximumWhileTyping: Story = { + args: { + schema: hemoglobinSchema, + uischema: hemoglobinUischema, + initialData: {}, + renderers, + validationMode: 'ValidateAndShow', + }, + parameters: { + docs: { + description: { + story: + 'Enter 35: inline error for maximum 25, but the field keeps showing 35 until you edit. Backspace to empty, then type 16.', + }, + }, + }, +}; + +export const BackspaceAndReplace: Story = { + args: { + schema: hemoglobinSchema, + uischema: hemoglobinUischema, + initialData: { hb_resultado: 35 }, + renderers, + validationMode: 'ValidateAndShow', + }, + parameters: { + docs: { + description: { + story: + 'Starts at 35 (over max). Focus, backspace all digits, type 16 — value must not snap to a bound while editing.', + }, + }, + }, +}; + +export const DecimalEntry: Story = { + args: { + schema: decimalSchema, + uischema: decimalUischema, + initialData: {}, + renderers, + keyboardEnterKeyHint: 'done', + }, +}; + +export const IntegerWithNextKey: Story = { + args: { + schema: ageSchema, + uischema: ageUischema, + initialData: { age: 30 }, + renderers, + keyboardEnterKeyHint: 'next', + }, + parameters: { + docs: { + description: { + story: + 'Inspect the input in mobile devtools: `inputmode=numeric`, `enterkeyhint=next`.', + }, + }, + }, +}; diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index e2614ab13..438eec16a 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -26,7 +26,14 @@ export interface CustomQuestionTypeProps { */ config: Record; - /** Callback to update the field value. Call with the new value. */ + /** + * Callback to update the field value. Call with a JSON number/boolean/string/etc. — + * never a display string for numeric schema types. + * + * **Numeric fields:** While focused, keep draft text locally; do not clamp to + * `minimum`/`maximum` on each keystroke. Commit typed numbers (including + * temporarily out-of-range values) and let AJV show validation errors. + */ onChange: (newValue: unknown) => void; /** Current validation state for this field */ From 7881cc2b6d9ff92f0e438db095cfb3068d7852df Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 23 Jun 2026 14:02:03 +0200 Subject: [PATCH 2/3] chore: formatting --- .../src/hooks/useNumericDraftInput.ts | 4 +--- .../src/renderers/NumberStepperRenderer.test.tsx | 16 +++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/formulus-formplayer/src/hooks/useNumericDraftInput.ts b/formulus-formplayer/src/hooks/useNumericDraftInput.ts index dff68a2d4..27aa1dab8 100644 --- a/formulus-formplayer/src/hooks/useNumericDraftInput.ts +++ b/formulus-formplayer/src/hooks/useNumericDraftInput.ts @@ -106,9 +106,7 @@ export function useNumericDraftInput({ ); const displayValue = - isFocused && draftText !== null - ? draftText - : formatNumericDisplay(data); + isFocused && draftText !== null ? draftText : formatNumericDisplay(data); const onFocus = useCallback(() => { if (!enabled) return; diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx index f87ce74b1..0b867074c 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx @@ -1,7 +1,13 @@ // @vitest-environment jsdom import React, { useState } from 'react'; import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { JsonForms } from '@jsonforms/react'; @@ -73,9 +79,7 @@ function readCommittedData(): Record { describe('NumberStepperRenderer', () => { it('uses text input with decimal inputMode and enterKeyHint', () => { - render( - , - ); + render(); const input = screen.getByRole('textbox'); expect(input).toHaveAttribute('type', 'text'); expect(input).toHaveAttribute('inputmode', 'decimal'); @@ -162,9 +166,7 @@ describe('NumberStepperRenderer', () => { }; const decimalUi = { type: 'Control', scope: '#/properties/value' }; - render( - , - ); + render(); const input = screen.getByRole('textbox'); await user.click(input); From 1a37513b42466f210feb7f578a2e84d04def2b94 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 23 Jun 2026 14:06:03 +0200 Subject: [PATCH 3/3] chore: updated test --- .../src/renderers/NumberStepperRenderer.test.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx index 0b867074c..e1bd31686 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.test.tsx @@ -11,6 +11,7 @@ import { import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; import Ajv from 'ajv'; import { theme } from '../theme/theme'; import { FormContext } from '../App'; @@ -18,7 +19,7 @@ import { numberStepperRenderer } from './NumberStepperRenderer'; const ajv = new Ajv({ allErrors: true, strict: false }); -const hbSchema = { +const hbSchema: JsonSchema7 = { type: 'object', properties: { hb_resultado: { @@ -30,7 +31,7 @@ const hbSchema = { }, }; -const hbUischema = { +const hbUischema: UISchemaElement = { type: 'Control', scope: '#/properties/hb_resultado', }; @@ -41,8 +42,8 @@ function NumericTestHarness({ initialData = {}, validationMode = 'ValidateAndShow' as const, }: { - schema: object; - uischema: object; + schema: JsonSchema7; + uischema: UISchemaElement; initialData?: Record; validationMode?: 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation'; }) { @@ -158,13 +159,16 @@ describe('NumberStepperRenderer', () => { it('commits decimal values for type number', async () => { const user = userEvent.setup(); - const decimalSchema = { + const decimalSchema: JsonSchema7 = { type: 'object', properties: { value: { type: 'number', multipleOf: 0.1 }, }, }; - const decimalUi = { type: 'Control', scope: '#/properties/value' }; + const decimalUi: UISchemaElement = { + type: 'Control', + scope: '#/properties/value', + }; render();