Skip to content

Commit 9aa2a57

Browse files
os-salesclaude
andauthored
fix(fields): the percent edit widget reads scale for its fraction width (objectui#9568) (#9804)
Fixes #9568 `Clause-②: no` ## What moved `PercentField` took `precision` as its decimal-place count in three places at once — the readonly face's `toFixed` width, the number input's `step` attribute, and the slider's step. `@objectstack/spec` declares the pair on the field face in its own words: `precision` is "Total digits (non-negative integer)" and `scale` is "Decimal places (non-negative integer)". So a percent field carrying an accurate `decimal(p, s)` pair was padded out to the column's TOTAL width: a `decimal(10, 2)` percent field rendered `25.0000000000%` readonly, offered `step="0.0000000001"` while editing, and moved its slider in `1e-10` increments. The step half is not cosmetic — it is a control nobody can operate for the value it is declared to edit. The width now comes from `scale`, read once and shared by all three faces, guarded with `typeof` rather than truthiness because `scale: 0` is a valid declaration (a percent field that edits whole percents). This is the correction `NumberField` in the same directory already carried for its own step, and the one objectui#9295 made on the read-only cell, the grid summary footer and the record summary chip. The edit widget is the face that did not move. ## The two decisions, taken explicitly The dispatch asked for the readonly face and the step to be decided separately rather than one falling out of the other. 1. **The readonly face** takes `scale`, and an absent `scale` keeps this widget's own two decimals. The rule applied is *move the MEMBER that is read, and nothing else*: the repair is therefore invisible to every percent field that declares neither member, which is pinned below as a MUST-NOT-CHANGE control. 2. **The input and slider step** take the SAME single `scale` read, with the same absent default. Tied on purpose: a control whose step is finer than its displayed width offers the user a value it then rounds away, and the widget's own pre-existing comment already tied slider granularity to the input's. ⚠️ **What this deliberately does NOT do.** `PercentCellRenderer` spells the absent-`scale` case as zero fraction digits and pins it, so the two faces still disagree for a field that declares nothing — `12%` in a grid cell against `12.35%` in the editor — exactly as they did before this branch. objectui#9568 is explicit that widget-versus-cell agreement is neither a goal nor a premise; closing that gap changes behaviour for every percent field in the tree that declares nothing, which needs its own ruling rather than arriving as a side effect of moving the member that is read. `typeof` and not `??`, since the dispatch's suggested route named `?? 2` as reintroducing the truthiness bug: measured, `??` is not that bug — `0 ?? 2` is `0`, so a declared `scale: 0` survives it and only `||` would drop it. The `typeof` guard was chosen for a different reason: it also refuses a `scale: "2"` arriving from JSON metadata, and it is the guard `NumberField` and this widget's own `max` read already use. ## The card's premises, re-measured on the base commit All four held, one with a correction worth reading. - **The defect sites.** Still exactly as cited — the `precision` read, the readonly `toFixed`, the `sliderStep` derivation, the input `step` and the slider `step` prop, all five in `PercentField.tsx` on the base commit. - **objectui#9295's reach.** Its pull request objectui#9566 merged with twelve files (`packages/fields/src/index.tsx`, `plugin-grid/src/useColumnSummary.ts`, `plugin-detail/src/DetailView.tsx`, their pins and one changeset) and no widget file among them. That absence is what leaves this card live. - **`scale` reaches this widget.** Confirmed at four independent points, not assumed: `FieldSchema` in `@objectstack/spec` declares `scale` on the field face for every field type; the metadata-admin designer offers the control for `type in ['number','currency','percent']`; `@objectstack/objectql`'s record validator enforces `scale` by REJECTION on the `percent` branch of its numeric arm; and both detail hosts that build the field bag for inline edit (`RecordDetailPanel` and `RelatedList`) forward `scale` explicitly next to `precision`. The widget reads its field bag through a cast, so no published type changes — see the acceptance notes for the type-surface gap that leaves. - ⭐ **The counter-precedent, read at source as dispatched.** objectui#4361 does NOT rule that an authored `precision` is a fraction width for percent-like widgets, so this card stands — but the card's stated reason is half wrong and should not be reused. The card says `precision` in `CurrencyField` is only "a parameter name for a width the caller already resolved"; in fact the widget itself resolves it, reading the field's own `precision` first and falling back to the currency's ISO 4217 minor-unit count, and objectui#4361's ruling is precisely that the authored `precision` wins over that derived count. What makes it not a precedent here is narrower and still decisive: its competing source is a currency's minor-unit count, never `scale`; a currency field's face carries no decimal-place member for `precision` to be confused with; and that card deliberately pushed the contract question upstream (objectstack#7918) instead of settling what an authored `precision` means. No fork, so no stop. ## Evidence `packages/fields/src/__tests__/PercentField.scale-9568.test.tsx` — seven cases, all through the component and reading the DOM it produced (the readonly span's text, the `step` attribute the browser's spinner obeys) plus one driven keystroke for the slider, whose step is a prop rather than an attribute. **Reverse verification** — the fix was committed first, then the pre-fix file was put back on disk, with the mutation proved by object hash (on-disk hash equal to the base blob, unequal to the HEAD blob) and by counting the injected and deleted anchors (`percentField?.precision ?? 2` back to 1 occurrence, `declaredScale` and `Math.pow(10, -scale)` down to 0). The restore was proved the same way, by hash and by an empty `git diff HEAD`, from a trap on `EXIT` `INT` `TERM` with absolute paths. | leg | vitest exit | result | | --- | --- | --- | | pre-fix file on disk | 1 | 6 failed, 1 passed | | fix restored | 0 | 7 passed | The one case that passes on the defect is the MUST-NOT-CHANGE control, which is the point of it. The six failures read as the card describes: `25.0000000000%` where `25.00%` is expected, `step` `0.0000000001` where `0.01` is expected, and one ArrowRight on the slider emitting `0.250000000001` instead of `0.2501`. ## Gates, with each exit code captured BEFORE any pipe `scripts/pm/dispatch-gates.mjs` refuses to answer for this repository by design, so this list was derived from this repo's own `package.json` scripts and `.github/workflows/`, against what the diff actually touches. | gate | exit | | --- | --- | | `pnpm exec vitest run packages/fields/` (166 files, 2863 tests) | 0 | | `pnpm exec turbo run build --filter=@object-ui/fields --concurrency=2` (10 tasks) | 0 | | `pnpm --filter @object-ui/fields run type-check` | 0 | | `pnpm exec eslint .` in `packages/fields` (249 files judged, 0 errors) | 0 | | `pnpm check:control-bytes` | 0 | | `pnpm check:new-line-citations` (0 added, re-run after the merge) | 0 | | `pnpm check:test-path-roots` | 0 | | `node scripts/check-changeset-presence.mjs` | 0 | | `pnpm changeset:check` | 0 | | `pnpm check:changeset-claims` | 0 | | `pnpm check:pending-changeset-literals` | 0 | | `pnpm check:designer-field-key-parity` | 0 | | `node scripts/check-component-surface-parity.mjs --type percent` (report-only) | 0 | Two readings behind that table rather than only the codes: - **The first `type-check` run exited 2 and was not a defect** — it was `Cannot find module '@object-ui/components'` and its siblings across the whole package, i.e. an unbuilt dependency closure. The build above is what makes the reading valid, and the 0 is the post-build run. - **The `eslint` narrowing is declared.** `pnpm lint` is `turbo run lint` over every package and belongs to CI; what ran here is this package's own `lint` script in full, so nothing inside `@object-ui/fields` was excluded — the file count comes from eslint's own `--format json` output and both changed files are in it. No config in this repo enables type-aware linting (no `parserOptions.project` / `projectService`), so this diff cannot move a verdict on a file it does not touch. - The suite and the pin were re-run after merging `origin/main`; the table's suite figures are from the pre-merge run of the same package, and the pin plus the three differential gates were re-run on the merged head. ## Acceptance notes Out-of-scope findings, with dedup words attached for the filing seat — none of them repaired here. 1. **`PercentFieldMetadata` in `@object-ui/types` declares `precision` and not `scale`.** `NumberFieldMetadata` beside it declares both (and `step`), and both detail hosts that forward `scale` have to cast to reach it. An author — and an AI author especially — reading the published interface is offered exactly the member that no longer does anything in this widget, and the member it now reads is undeclared. This is why the fix needed no type change (the widget reads its field bag through a cast) and why it was NOT folded in: widening an exported type would void this card's `Clause-②: no`. Dedup words: `PercentFieldMetadata scale missing` · `percent field metadata precision only` · `types field-types percent scale` · `PercentFieldMetadata precision declared` · `percent metadata scale undeclared`. 2. **A spec-valid `scale` above 100 crashes the render, on both faces.** `FieldSchema` accepts `scale` as any non-negative integer with no upper bound, while `Number.prototype.toFixed` throws `RangeError` above 100 digits (measured) and `Intl.NumberFormat` refuses the same range for `maximumFractionDigits` — so the widget and `PercentCellRenderer` both throw rather than render. Pre-existing and unchanged by this branch (the trigger moves from `precision` to `scale`); not repaired here because one ruling should cover both faces and neither the clamp nor the refusal is this widget's call to invent. Dedup words: `scale above 100 RangeError` · `toFixed digits out of range percent` · `maximumFractionDigits out of range` · `spec scale no upper bound` · `percent render throws large scale`. 3. **`scale` means stored decimals to the write path and displayed decimals to the display path, 100x apart for a fraction-stored percent.** A percent field stores a 0-1 fraction unless it declares `max > 1`, so the record validator judges `scale` against the fraction (`33.333%` needs `scale: 5`, as the spec's own numeric-column note states), while objectui#9295's landed and pinned convention applies `scale` to the percentage-point value. A fraction-stored field declaring `scale: 2` therefore renders and steps in hundredths of a percentage point while the platform will refuse a write finer than one whole percent. This branch deliberately follows the landed display convention rather than re-litigating it — the two faces agreeing with each other is worth more than this widget alone being right — but the divergence is real and spans both repositories. Dedup words: `percent scale fraction storage magnitude` · `max_scale percent fraction 100x` · `percentScaleOf scale display` · `percent scale stored vs displayed` · `record validator max_scale percent widget step`. Noted, not filed: - **The absent-`scale` disagreement between the widget and the cell** (`12.35%` against `12%`). A design question with a real cost either way, not a defect, and the card names it as part of the question rather than the answer. Successor: the queued same-convention siblings objectui#9574 (`plugin-form`) and objectui#9575 (`plugin-list`) are the next seats in this file family and would be re-priced against whatever ruling settles it. - The widget's top doc comment said "configurable decimal precision" and now names `scale`; the recorded Chromium measurement in the same file said it was driven with `precision: 2` and now says "a two-decimal width", because the field that produces that width is no longer `precision`. Neither restates a measurement that was not taken. Session that produced this branch: `session_01Xm4WFhEe5mwcgyqHjxR2hn`, seat `domain:ui#3`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- _Generated by [Claude Code](https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31d34cb commit 9aa2a57

3 files changed

Lines changed: 263 additions & 7 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@object-ui/fields': minor
3+
---
4+
5+
The percent EDIT WIDGET reads `scale` for its fraction width, not `precision`
6+
(objectui#9568).
7+
8+
`PercentField` took `precision` as the decimal-place count for three things at
9+
once: the readonly face's `toFixed` width, the number input's `step` attribute
10+
and the slider's step. `@objectstack/spec` declares the pair on the field face
11+
in its own words — `precision` is "Total digits (non-negative integer)" and
12+
`scale` is "Decimal places (non-negative integer)" — so a percent field carrying
13+
an accurate `decimal(p, s)` pair was padded out to the column's TOTAL width. A
14+
`decimal(10, 2)` percent field rendered `25.0000000000%` readonly and offered
15+
`step="0.0000000001"` while editing, with a slider that moved in `1e-10`
16+
increments. The step half is not cosmetic: it makes the control unusable for the
17+
value it is declared to edit.
18+
19+
This is the correction `NumberField` beside it already carried for its own step,
20+
and the one objectui#9295 made on the read-only cell, the grid summary footer
21+
and the record summary chip. The edit widget is the face that did not move.
22+
23+
**Behaviour moves in two directions**, for percent fields that declare these
24+
members:
25+
26+
- A percent field declaring `scale` now honours it, in all three places.
27+
Declaring `scale: 0` previously rendered `25.00%` and stepped by `0.01`; it
28+
now renders `25%` and steps by `1`.
29+
- A percent field declaring `precision` no longer pads to it. Declaring
30+
`precision: 10` previously rendered `25.0000000000%` and stepped by `1e-10`;
31+
it now falls to the widget's own two-decimal default.
32+
33+
**Migration.** Restate the intended fraction width as `scale`, the member the
34+
contract has always declared for it. Metadata carrying an accurate
35+
`decimal(p, s)` pair — both members, as a database column exposes them — needs no
36+
change and simply stops being padded.
37+
38+
**Unchanged: a percent field that declares neither member.** The absent-`scale`
39+
width stays this widget's own two decimals (`12.35%`, `step="0.01"`), so the
40+
repair is invisible to metadata that declares nothing, and the whole-percent
41+
convention this widget detects from a declared `max` above 1 is untouched.
42+
43+
⚠️ That default is deliberate and it is NOT the cell's: `PercentCellRenderer`
44+
spells the same absence as zero fraction digits. The two faces therefore still
45+
disagree for a field that declares nothing — `12%` in a grid cell against
46+
`12.35%` in the editor — as they did before this change. objectui#9568 declines
47+
to make widget-versus-cell agreement a premise, and closing that gap needs its
48+
own ruling rather than arriving as a side effect of moving the member that is
49+
read.
50+
51+
`CurrencyField`'s own read of `precision` is untouched and is not the same
52+
shape: there the competing source is the currency's ISO 4217 minor-unit count,
53+
and objectui#4361 ruled an authored `precision` wins over that. It ruled nothing
54+
about `scale`. `CurrencyConfigSchema.precision` is a third surface again, with
55+
the opposite convention and its own `scale` alias, and the spec warns against
56+
conflating it with the field face.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#9568 — the percent EDIT WIDGET read `precision` as a fraction
11+
* width, so a `decimal(10, 2)` percent field showed `25.0000000000%` and
12+
* stepped by `1e-10`.
13+
*
14+
* `@objectstack/spec` declares the pair on the field face in its own words —
15+
* `precision` is "Total digits (non-negative integer)" and `scale` is "Decimal
16+
* places (non-negative integer)" — so the member a decimal-place width comes
17+
* from is `scale`. `NumberField` in the same directory already carries that
18+
* correction for its step, and objectui#9295 made it on the read-only cell,
19+
* the grid summary footer and the detail summary chip. This widget is the face
20+
* that did not move, and the one the user TYPES into.
21+
*
22+
* ── Why the assertions are on the rendered control ──────────────────────
23+
* The defect is what the user sees and what the spinner offers, so every row
24+
* below goes through the component and reads the DOM it produced: the readonly
25+
* span's text, and the `step` attribute the browser's spinner and keyboard
26+
* affordances obey. The slider's step is not a DOM attribute, so it is driven
27+
* instead — one keyboard increment, whose emitted value IS the step.
28+
*
29+
* ── What fails before the repair ────────────────────────────────────────
30+
* Every `scale`-declaring row. `{ precision: 10, scale: 2 }` rendered
31+
* `25.0000000000%` readonly, `step="0.0000000001"` editable, and a slider
32+
* increment of `1e-10`; `{ scale: 0 }` was ignored outright because
33+
* `precision` was absent, so the width fell to the widget's own 2.
34+
*
35+
* ── The ABSENT-`scale` case is a DECISION, not a leftover ───────────────
36+
* It keeps this widget's own 2 (`12.35%`, `step="0.01"`), so the repair is
37+
* invisible to a percent field that declares neither member — pinned below as
38+
* a MUST-NOT-CHANGE control. ⚠️ `PercentCellRenderer` spells the same absence
39+
* `0` and pins that, so the two faces still disagree when nothing is declared.
40+
* objectui#9568 declines to make widget-versus-cell agreement a premise; this
41+
* change neither widens nor closes that disagreement.
42+
*/
43+
import { describe, it, expect, vi, afterEach } from 'vitest';
44+
import React from 'react';
45+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
46+
import '@testing-library/jest-dom';
47+
48+
import { PercentField } from '../widgets/PercentField';
49+
50+
afterEach(() => cleanup());
51+
52+
const percentField = (field: Record<string, unknown>) =>
53+
({ type: 'percent', name: 'rate', ...field }) as any;
54+
55+
const renderReadonly = (value: number, field: Record<string, unknown> = {}) =>
56+
render(
57+
<PercentField value={value} onChange={() => {}} field={percentField(field)} readonly />,
58+
);
59+
60+
const renderEditable = (
61+
value: number,
62+
field: Record<string, unknown> = {},
63+
onChange: (v: unknown) => void = () => {},
64+
) =>
65+
render(
66+
<PercentField value={value} onChange={onChange as any} field={percentField(field)} />,
67+
);
68+
69+
/** The width the readonly face renders, percent sign included. */
70+
const readonlyText = () => document.body.textContent ?? '';
71+
72+
/** The granularity the browser's spinner and arrow keys obey. */
73+
const inputStep = () => screen.getByRole('spinbutton').getAttribute('step');
74+
75+
describe('PercentField reads `scale`, not `precision` (objectui#9568)', () => {
76+
it('does not pad a decimal(10, 2) percent field out to ten fraction digits', () => {
77+
// The card's headline reading, on the readonly face.
78+
renderReadonly(0.25, { precision: 10, scale: 2 });
79+
80+
expect(readonlyText()).toContain('25.00%');
81+
expect(readonlyText()).not.toContain('25.0000000000%');
82+
});
83+
84+
it('offers a usable spinner step for a decimal(10, 2) percent field', () => {
85+
// The half that is not cosmetic: `1e-10` is not a granularity anybody can
86+
// operate, and it is what the browser's arrow keys and spinner obeyed.
87+
renderEditable(0.25, { precision: 10, scale: 2 });
88+
89+
expect(inputStep()).toBe('0.01');
90+
expect(inputStep()).not.toBe('0.0000000001');
91+
});
92+
93+
it('steps the slider by the same declared width as the input', () => {
94+
// The slider's step is a prop, not an attribute, so it is DRIVEN: one
95+
// ArrowRight from a stored 0.25 emits the next value one step away, in
96+
// display magnitude (25 -> 25.01), stored back as a fraction. Before the
97+
// repair the same keystroke emitted 0.25 + 1e-12.
98+
const onChange = vi.fn();
99+
renderEditable(0.25, { precision: 10, scale: 2 }, onChange);
100+
101+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' });
102+
103+
expect(onChange).toHaveBeenCalledTimes(1);
104+
expect(onChange.mock.calls[0][0]).toBeCloseTo(0.2501, 6);
105+
});
106+
107+
it('honours `scale: 0`, which truthiness would have dropped', () => {
108+
// `scale: 0` is a valid declaration — a percent field that edits whole
109+
// percents. Before the repair this field had NO declared width at all
110+
// (`precision` absent), so it rendered two decimals and stepped by 0.01.
111+
renderReadonly(0.25, { scale: 0 });
112+
expect(readonlyText()).toContain('25%');
113+
expect(readonlyText()).not.toContain('25.00%');
114+
cleanup();
115+
116+
renderEditable(0.25, { scale: 0 });
117+
expect(inputStep()).toBe('1');
118+
});
119+
120+
it('ignores `precision` entirely when no `scale` is declared', () => {
121+
// decimal(10, 0) — ten total digits. `precision` alone must move nothing,
122+
// in either face.
123+
renderReadonly(0.25, { precision: 10 });
124+
expect(readonlyText()).toContain('25.00%');
125+
expect(readonlyText()).not.toContain('25.0000000000%');
126+
cleanup();
127+
128+
renderEditable(0.25, { precision: 10 });
129+
expect(inputStep()).toBe('0.01');
130+
expect(inputStep()).not.toBe('0.0000000001');
131+
});
132+
133+
it('leaves a field declaring neither member exactly where it was', () => {
134+
// MUST-NOT-CHANGE control: the absent-`scale` width stays this widget's
135+
// own 2, so this repair is invisible to every field that declares nothing.
136+
renderReadonly(0.12345);
137+
expect(readonlyText()).toContain('12.35%');
138+
cleanup();
139+
140+
renderEditable(0.12345);
141+
expect(inputStep()).toBe('0.01');
142+
});
143+
144+
it('applies the declared width to the whole-percent convention too', () => {
145+
// A field declaring `max > 1` stores percentage POINTS, and that detection
146+
// is untouched by this repair: a stored 25 is 25%, rendered at the width
147+
// `scale` declares.
148+
renderReadonly(25, { max: 100, precision: 10, scale: 1 });
149+
expect(readonlyText()).toContain('25.0%');
150+
expect(readonlyText()).not.toContain('25.0000000000%');
151+
cleanup();
152+
153+
renderEditable(25, { max: 100, precision: 10, scale: 1 });
154+
expect(inputStep()).toBe('0.1');
155+
});
156+
});

‎packages/fields/src/widgets/PercentField.tsx‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,54 @@ import { toDomProps } from './toDomProps.js';
55
import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js';
66

77
/**
8-
* PercentField - Percentage input with configurable decimal precision
8+
* PercentField - Percentage input whose decimal places follow the field's
9+
* declared `scale` (see the read below)
910
* Stores values as decimals (0-1) and displays as percentages (0-100%)
1011
* Includes a slider for interactive control.
1112
*/
1213
export function PercentField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
1314
const percentField = field as any;
14-
const precision = percentField?.precision ?? 2;
15+
/**
16+
* Decimal places come from `scale`, NOT `precision` (objectui#9568) — the
17+
* correction `NumberField` in this directory already carries, and the one
18+
* objectui#9295 made on the read-only cell, the grid summary footer and the
19+
* detail summary chip. This widget is the face that did not move.
20+
*
21+
* `@objectstack/spec` declares the pair on the field face in its own words:
22+
* `precision` is "Total digits (non-negative integer)" and `scale` is
23+
* "Decimal places (non-negative integer)". Reading `precision` padded every
24+
* value out to the column's TOTAL width, so a decimal(10, 2) percent field
25+
* rendered `25.0000000000%` and offered a `1e-10` step — a step that is not
26+
* a cosmetic defect but an unusable control for the value it is declared to
27+
* edit.
28+
*
29+
* ⛔ NOT `CurrencyConfigSchema.precision`, which is a different surface with
30+
* the opposite convention and its own `scale` alias; the spec warns against
31+
* conflating the two at the field-face declaration itself. And ⛔ not
32+
* `CurrencyField`'s read of `precision` either: there the competing source is
33+
* the currency's own ISO 4217 minor-unit count, and objectui#4361 ruled an
34+
* authored `precision` wins over THAT. It ruled nothing about `scale`, which
35+
* a currency field's face does not carry a meaning for, and it pushed the
36+
* contract question upstream rather than settling it here.
37+
*
38+
* `typeof`, not truthiness: `scale: 0` is a valid declaration (a percent
39+
* field that edits whole percents) and `||` would silently drop it — the
40+
* reason `NumberField` guards its own read the same way, and the guard the
41+
* `max` read below already uses. `??` would keep a `0` too, but it also
42+
* keeps a `scale: "2"` arriving from JSON metadata, and inventing a width
43+
* from a string is the consumer-side guessing AGENTS.md #0.1 refuses.
44+
*
45+
* An ABSENT `scale` keeps this widget's own 2. The repair moves the MEMBER
46+
* that is read and nothing else, so it is invisible to every percent field
47+
* that declares neither member. ⚠️ Stated rather than papered over: that
48+
* leaves this widget and `PercentCellRenderer` still disagreeing when
49+
* nothing is declared — the cell spells the same absence `0` and pins it —
50+
* which is a disagreement objectui#9568 explicitly declines to make a
51+
* premise and this change neither widens nor closes. Whether the two faces
52+
* should agree at all needs its own ruling, not a side effect of this one.
53+
*/
54+
const declaredScale = percentField?.scale;
55+
const scale = typeof declaredScale === 'number' ? declaredScale : 2;
1556

1657
// Before the readonly return below: hooks are unconditional (objectui#6780).
1758
const { refusal, readBadInput } = useBadInputRefusal('12.5');
@@ -32,7 +73,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam
3273
if (value == null) return <EmptyValue />;
3374
return (
3475
<span className="text-sm font-medium tabular-nums">
35-
{toDisplay(value).toFixed(precision)}%
76+
{toDisplay(value).toFixed(scale)}%
3677
</span>
3778
);
3879
}
@@ -52,7 +93,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam
5293
* change event fires.
5394
*
5495
* Measured on Chromium 141.0.7390.37 (Playwright 1.62.1), driving THIS
55-
* widget (fraction convention, `precision: 2`):
96+
* widget (fraction convention, a two-decimal width):
5697
*
5798
* ```
5899
* typed "12abc" -> box.value "12" onChange(0.12)
@@ -122,8 +163,11 @@ export function PercentField({ value, onChange, field, readonly, error, classNam
122163
onChange(nextValue as any);
123164
};
124165

125-
// Derive slider step from precision so slider granularity matches the input
126-
const sliderStep = Math.pow(10, -precision);
166+
// Derive the slider step from the SAME `scale` the input and the readonly
167+
// face take, so slider granularity matches the input and neither offers a
168+
// value the other would round away (objectui#9568 kept the two tied on
169+
// purpose — the decision is one member, read once).
170+
const sliderStep = Math.pow(10, -scale);
127171

128172
return (
129173
<div className="space-y-2">
@@ -137,7 +181,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam
137181
placeholder={percentField?.placeholder || '0'}
138182
disabled={readonly || props.disabled}
139183
className={cn('pr-8', refusal ? BAD_INPUT_BORDER : '', className)}
140-
step={Math.pow(10, -precision).toFixed(precision)}
184+
step={Math.pow(10, -scale).toFixed(scale)}
141185
// `refusal` is this widget's OWN reading, which no host can produce;
142186
// `error` keeps its single author (objectui#3222 / objectui#6716).
143187
aria-invalid={!!error || !!refusal}

0 commit comments

Comments
 (0)