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({