From b78ed64fff133ed7d32e947ba598723578775823 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:01:17 +0000 Subject: [PATCH 1/2] fix(fields): honour disabled on the registered fullscreen long-text path (#3402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disabled` reached the inline control of `TextAreaField` / `RichTextField` and nothing else. `showFullscreenButton` did not consult it, neither call site forwarded it, and `FullscreenFieldEditor` did not declare the prop at all — so a disabled long-text or rich-text field was greyed out inline while its expand button stayed live, its dialog accepted any edit, and "Done" wrote that edit back through `onCommit`. The issue carried static evidence only, so the premise was reproduced with a dynamic probe first: toggle `disabled=false`, dialog opened, dialog input `disabled=false`, `onChange` called with "EDITED WHILE DISABLED" — on both widgets, and again with `disabled` flipped true under an already-open dialog. `FullscreenFieldEditor` now declares `disabled`, shaped like the built-in path's `locked` gate (#3400 / PR #3401): the toggle stays but is disabled and refuses to open, the dialog's editor is disabled via a new third `children` argument, and "Done" is disabled and gated before `onCommit`. The dialog holds on its own rather than trusting the button, because `disabled` also carries the form's `isSubmitting` and can flip true while the dialog is open. Cancel and Esc stay live so a submit in flight cannot trap the user in a modal. `readonly` is untouched: both widgets early-return a read-only display before the affordance is computed, which is why readonly was never part of this defect, and no `readonly` prop is added here that no host would produce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../fields-fullscreen-editor-disabled.md | 7 + .../src/widgets/FullscreenFieldEditor.tsx | 78 +++++- packages/fields/src/widgets/RichTextField.tsx | 12 +- packages/fields/src/widgets/TextAreaField.tsx | 12 +- .../RichTextField.fullscreenDisabled.test.tsx | 244 +++++++++++++++++ .../TextAreaField.fullscreenDisabled.test.tsx | 246 ++++++++++++++++++ 6 files changed, 592 insertions(+), 7 deletions(-) create mode 100644 .changeset/fields-fullscreen-editor-disabled.md create mode 100644 packages/fields/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx create mode 100644 packages/fields/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx diff --git a/.changeset/fields-fullscreen-editor-disabled.md b/.changeset/fields-fullscreen-editor-disabled.md new file mode 100644 index 0000000000..77384b3188 --- /dev/null +++ b/.changeset/fields-fullscreen-editor-disabled.md @@ -0,0 +1,7 @@ +--- +'@object-ui/fields': patch +--- + +`TextAreaField` / `RichTextField` now honour `disabled` on their fullscreen editing path. `disabled` used to reach the inline control only: `showFullscreenButton` never consulted it, neither widget forwarded it to `FullscreenFieldEditor`, and that component did not declare the prop at all. A disabled long-text or rich-text field therefore sat correctly greyed out next to a live expand button whose dialog accepted any edit and wrote it straight back through `onCommit` — reproduced dynamically before the fix as toggle `disabled=false`, dialog input `disabled=false`, `onChange` called with "EDITED WHILE DISABLED". The state was easy to miss precisely because the visible control looked right. + +`FullscreenFieldEditor` now declares `disabled`: the expand button stays (disabled means "not interactive, muted", unlike `readonly`, which suppresses the affordance entirely via each widget's read-only early return) but is disabled and refuses to open, the dialog's editor is disabled through a new third `children` argument, and "Done" is both disabled and gated before `onCommit`. The dialog locks on its own rather than trusting the button, because the form renderer folds `isSubmitting` into `disabled` — so a submit starting while the dialog was already open used to leave the field editable for the duration of the submit. Cancel and Esc stay live in every state. This is the registered-widget half of the same defect #3400 / #3401 fixed on the built-in `form.tsx` path, so both render paths now give the same metadata the same behaviour (#3402). diff --git a/packages/fields/src/widgets/FullscreenFieldEditor.tsx b/packages/fields/src/widgets/FullscreenFieldEditor.tsx index c9cc35d954..70cc3d4fb8 100644 --- a/packages/fields/src/widgets/FullscreenFieldEditor.tsx +++ b/packages/fields/src/widgets/FullscreenFieldEditor.tsx @@ -50,6 +50,46 @@ import { Maximize2, Check, X } from 'lucide-react'; * edit the user may still cancel. "Cancel" therefore needs no undo — nothing * was written. * + * ## `disabled` (objectui#3402) + * + * The dialog is a SECOND editing surface for the same value, so a field that is + * not interactive has to be not interactive here too. It was not: hosts landed + * `disabled` on their inline control only, this component never declared the + * prop at all, and a disabled long-text field therefore sat correctly greyed out + * next to a live expand button — click it, type anything, press "Done", and the + * edit went into form state through `onCommit`. Measured on `origin/main` + * before the fix: inline `disabled=true`, toggle `disabled=false`, dialog input + * `disabled=false`, `onChange` called with "EDITED WHILE DISABLED". + * + * The gate is shaped like the built-in path's (`FullscreenTextarea` in + * `components/src/renderers/form/form.tsx`, objectui#3400/PR #3401), because + * one form-level setting must keep producing one behaviour on both paths: + * + * - the toggle STAYS but is `disabled` — `disabled` means "not interactive, + * muted", not "shown plainly", which is `readonly`'s job (and `readonly` + * never reaches this component at all — see below); + * - `openFullscreen` refuses independently of the attribute, since a + * programmatic dispatch and a lost `pointer-events` rule both get past it; + * - the dialog locks on its OWN — `disabled` is not merely a static flag, it is + * also the form's `isSubmitting`, which flips to true while the dialog may + * already be open. At that moment the toggle is no longer the gate. So the + * editor is told (third `children` argument) and "Done" is disabled; + * - and `onCommit` is gated, because that is the single point where a value can + * leave this component for host state. Nothing native guards it: it is a + * click handler on a different control reading React state. + * + * "Cancel" and `Esc` stay live in every state — a dialog that goes disabled + * mid-edit must still be closable, or a submit in flight traps the user in it. + * + * ## Why there is no `readonly` prop + * + * Both hosts early-return a read-only DISPLAY before they compute the affordance + * (`TextAreaField.tsx`, `RichTextField.tsx`), so this component is never + * rendered for a read-only field and a `readonly` prop here would be declared + * with no producer — the shape this package keeps deleting (objectui#3232/#3233). + * A future host that renders an editor for read-only fields must add the prop + * AND the producer together; do not add it "for symmetry" beforehand. + * * ## `testIdPrefix` * * Each host passes its own (`textarea`, `richtext`), yielding the same @@ -76,12 +116,35 @@ export interface FullscreenFieldEditorProps { label?: string; /** Namespace for this widget's fullscreen test ids. See above. */ testIdPrefix: string; + /** + * The host field is not interactive (objectui#3402). Disables the toggle, the + * "Done" button and the write-back, and is handed to `children` so the host's + * own editor renders disabled too. See the `disabled` section above for why + * each of those is a separate line rather than belt-and-braces. + * + * **Producer**: the widget's `disabled` prop, which the form renderer computes + * as `disabled || fieldDisabled || isSubmitting || optionGroupGated` and + * forwards to registered widgets (`stripRegisteredFieldProps` does not strip + * it). + */ + disabled?: boolean; /** * The editor itself, rendered inside the dialog body against the draft. The * host passes the SAME editor it renders inline, so "fullscreen" is a size * change rather than a second, poorer editing surface. + * + * The third argument is this component's `disabled`, and the host is expected + * to put it on the control it renders: only the host knows which element its + * editor's disabled state belongs on. Ignoring it is not a write-back hole — + * `onCommit` is gated here regardless — but it does leave a control that looks + * editable while the field is not, so both in-repo hosts apply it and their + * tests pin it. */ - children: (draft: string, setDraft: (next: string) => void) => React.ReactNode; + children: ( + draft: string, + setDraft: (next: string) => void, + disabled: boolean, + ) => React.ReactNode; /** Optional footer status for the draft (e.g. a character counter). */ footer?: (draft: string) => React.ReactNode; /** @@ -97,6 +160,7 @@ export function FullscreenFieldEditor({ onCommit, label, testIdPrefix, + disabled = false, children, footer, toggleClassName, @@ -105,12 +169,17 @@ export function FullscreenFieldEditor({ const [draft, setDraft] = useState(value ?? ''); const openFullscreen = () => { + if (disabled) return; setDraft(value ?? ''); setOpen(true); }; const cancelFullscreen = () => setOpen(false); const commitFullscreen = () => { - onCommit(draft); + // THE gate: the one point where a value leaves this component for host + // state. `disabled` can flip to true while this dialog is open (it carries + // the form's `isSubmitting`), so this is checked here and not only on the + // way in. Closing is unconditional — see "Cancel and Esc stay live" above. + if (!disabled) onCommit(draft); setOpen(false); }; @@ -119,8 +188,10 @@ export function FullscreenFieldEditor({