Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/6780-number-input-badinput-announce.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@object-ui/fields": minor
---

A `type="number"` field no longer displays one value and stores another in
silence (objectui#6780).

`NumberField`, `CurrencyField`, `PercentField` and `GeolocationField` now
announce when the browser reports `validity.badInput` — it is holding text it
cannot read. The control is marked `aria-invalid="true"` and draws
`Not saved: the text in this box is not a number. Enter a plain decimal
(example: …).`, reusing the refusal shape objectui#6716 introduced for
`LocationField`.

Measured in Chromium 141.0.7390.37 (Playwright 1.62.1), typing `1e` into an
empty number box leaves it **visibly displaying `1e`** while `.value` reads the
empty string. Before this change the widget emitted `null`, `aria-invalid`
stayed `"false"`, and nothing was said — on a money field. Nine keyboard
reachable states behave that way (`1e`, `1e-`, `1e+`, `5e`, `-`, `.`, `+`, `-.`,
`e`), and none of the six values a real browser actually emits trips the guard.

Both a change arm and a **blur** arm are wired. Pasting `1e` into an empty box
never moves `.value` off `''`, so React's input-value tracking suppresses the
change event entirely and blur is the only arm that sees it. `PercentField`,
`NumberField` and `GeolocationField` had no `onBlur` before; the new one
composes any handler a host supplied rather than replacing it.

The guard ANNOUNCES; it deliberately does not refuse. Refusing would leave the
React `value` prop unchanged, and React's `updateInput` writes it back over the
raw text — wiping the very entry the message points at.

⚠️ **Filtering truncation stays silent, and cannot be made otherwise.** Pasting
`1.2.3` into a currency field stores `1.23`; `0x10` stores `10`. The browser
discards those characters as they arrive, before any widget code runs, so no
widget-side guard can refuse them — only abandoning `type="number"` could, which
would reverse objectui#2572's deliberate `min`/`max`/`step` and mobile numeric
keyboard affordances. This asymmetry is documented for users in
`content/docs/guide/fields.md` and on the currency, percent and number field
pages, because a control that warns about `1e` while silently truncating `1.2.3`
teaches people that no warning means the value is right.
18 changes: 18 additions & 0 deletions content/docs/fields/currency.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,24 @@ const amount: CurrencyFieldMetadata = {
The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

## What the browser rewrites before this field sees it

This widget renders a native `type="number"` input, so the browser decides what
the box accepts. Two things can happen and **only one of them is announced**:

- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone
`.` — leaves the box visibly showing what was typed while its value reads
empty. The field is marked `aria-invalid` and draws
*"Not saved: the text in this box is not a number."*
- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting
``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is
possible here — the browser discards the extra characters as they arrive, so
nothing reaches ObjectUI to check.

⛔ **"No warning" therefore does not mean "the value is right."** Full
explanation and the reasoning: [What a number field silently
rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites).

## Supported Currencies

- **USD**: US Dollar ($)
Expand Down
18 changes: 18 additions & 0 deletions content/docs/fields/number.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,24 @@ const quantity: NumberFieldMetadata = {
The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

## What the browser rewrites before this field sees it

This widget renders a native `type="number"` input, so the browser decides what
the box accepts. Two things can happen and **only one of them is announced**:

- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone
`.` — leaves the box visibly showing what was typed while its value reads
empty. The field is marked `aria-invalid` and draws
*"Not saved: the text in this box is not a number."*
- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting
``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is
possible here — the browser discards the extra characters as they arrive, so
nothing reaches ObjectUI to check.

⛔ **"No warning" therefore does not mean "the value is right."** Full
explanation and the reasoning: [What a number field silently
rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites).

## Use Cases

- **Quantities**: Order quantities, stock levels
Expand Down
18 changes: 18 additions & 0 deletions content/docs/fields/percent.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,24 @@ const discountRate: PercentFieldMetadata = {
The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

## What the browser rewrites before this field sees it

This widget renders a native `type="number"` input, so the browser decides what
the box accepts. Two things can happen and **only one of them is announced**:

- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone
`.` — leaves the box visibly showing what was typed while its value reads
empty. The field is marked `aria-invalid` and draws
*"Not saved: the text in this box is not a number."*
- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting
``1.2.3`` stores ``0.0123``, and `0x10` stores ``0.1``. No warning is
possible here — the browser discards the extra characters as they arrive, so
nothing reaches ObjectUI to check.

⛔ **"No warning" therefore does not mean "the value is right."** Full
explanation and the reasoning: [What a number field silently
rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites).

## Value Conversion

The percent field handles automatic conversion:
Expand Down
43 changes: 43 additions & 0 deletions content/docs/guide/fields.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,49 @@ Object UI comes with built-in support for the standard [ObjectStack Protocol](ht
| `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) |
| `owner` | Record owner — a `user` field, typically read-only and stamped with the current user |

## What a number field silently rewrites

`number`, `currency`, `percent` and `geolocation` render a native
`type="number"` input. The browser — not ObjectUI — decides what that box will
accept, and it rewrites some entries **before any widget code runs**. Two
different things can happen, and only one of them is announced.

### Announced: text the browser cannot read

If the box is left holding something that is not a complete number, the browser
reports `validity.badInput` and these widgets now say so: the control is marked
`aria-invalid="true"` and a message is drawn under it —

> Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56).

Measured in Chromium 141, typing any of `1e`, `1e-`, `1e+`, `5e`, `-`, `.`,
`+`, `-.` or `e` leaves the box **visibly displaying** what was typed while its
value reads empty. Before this was announced, the field simply stored nothing
and said nothing.

### ⚠️ NOT announced: entries the browser silently truncates

This is the important limitation, and it is deliberate rather than an oversight.

| you paste / type | the field stores |
|---|---|
| `1.2.3` | `1.23` |
| `0x10` | `10` |
| `12abc` | `12` |

**No warning is shown for these, and no widget-side check can add one.** The
browser filters the keystrokes or the pasted text as it arrives, so by the time
ObjectUI sees the field the discarded characters are already gone — there is
nothing left to detect. This is native `type="number"` behaviour; recovering it
would mean giving up the numeric keyboard on mobile and the `min`/`max`/`step`
spinner on every numeric field in the product.

⛔ **So do not read "no warning" as "the value is correct."** A warning means the
browser could not read the box at all. Silence means the browser read
*something* — which may be less than you typed. When exact input matters
(reference codes, serial numbers, anything where `1.2.3` is meaningful), declare
a `text` field, not a numeric one.

## Using Renderers in Custom Components

If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel.
Expand Down
24 changes: 24 additions & 0 deletions packages/fields/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,30 @@ Supported types out of the box:
- **Media**: `file`, `image`
- **System**: `formula`, `summary`, `auto_number`

### `type="number"` widgets: what is announced and what is not

`NumberField`, `CurrencyField`, `PercentField` and `GeolocationField` all render
a native `type="number"` input, so the **browser** decides what the box accepts.
They share one reading of that, in `widgets/numberBadInput.tsx`:

- **Announced.** When the browser reports `validity.badInput` — the box is
holding text it cannot convert, e.g. a typed `1e`, which Chromium keeps
DISPLAYING while `.value` reads `''` — the control is marked `aria-invalid`
and draws a `Not saved: …` message, reusing objectui#6716's refusal shape.
Both a change arm and a blur arm are wired, because pasting into an empty box
never moves `.value` and so fires no React change event at all.
- ⚠️ **Not announced, and not announceable.** Entries the browser silently
**truncates**: `1.2.3` stores `1.23`, `0x10` stores `10`. The characters are
discarded as they arrive, before any handler here runs, so no widget-side
guard can refuse them. Recovering them would mean abandoning `type="number"`
and with it the mobile numeric keyboard and the `min`/`max`/`step` spinner
(objectui#2572).

⛔ Silence therefore means "the browser read *something*", never "the value is
correct". User-facing wording lives in
[the fields guide](../../content/docs/guide/fields.md). The measured browser vs
happy-dom matrix is in `src/__tests__/numberInputBrowserReadings.ts`.

### Rendering form field widgets outside the form

The full widget surface is exported for consumers that render field widgets
Expand Down
Loading
Loading