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
7 changes: 7 additions & 0 deletions .changeset/fields-fullscreen-editor-disabled.md
Original file line numberDiff line numberDiff line change
@@ -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).
78 changes: 75 additions & 3 deletions packages/fields/src/widgets/FullscreenFieldEditor.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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;
/**
Expand All@@ -97,6 +160,7 @@ export function FullscreenFieldEditor({
onCommit,
label,
testIdPrefix,
disabled = false,
children,
footer,
toggleClassName,
Expand All@@ -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);
};

Expand All@@ -119,8 +188,10 @@ export function FullscreenFieldEditor({
<button
type="button"
onClick={openFullscreen}
disabled={disabled}
className={cn(
'absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm transition-colors',
'disabled:opacity-50 disabled:pointer-events-none',
toggleClassName,
)}
aria-label={`Edit ${label ?? 'text'} fullscreen`}
Expand All@@ -137,7 +208,7 @@ export function FullscreenFieldEditor({
<DialogHeader className="p-4 border-b">
<DialogTitle className="text-base">{label ?? 'Edit text'}</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 p-4">{children(draft, setDraft)}</div>
<div className="flex-1 min-h-0 p-4">{children(draft, setDraft, disabled)}</div>
<DialogFooter className="p-3 border-t flex-row justify-between sm:justify-end gap-2">
{footer?.(draft)}
<div className="flex gap-2 ml-auto">
Expand All@@ -147,6 +218,7 @@ export function FullscreenFieldEditor({
<Button
type="button"
onClick={commitFullscreen}
disabled={disabled}
data-testid={`${testIdPrefix}-fullscreen-save`}
>
<Check className="size-4 mr-1" /> Done
Expand Down
12 changes: 10 additions & 2 deletions packages/fields/src/widgets/RichTextField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -120,6 +120,12 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
// Same single read as `TextAreaField`: the field metadata is the only
// carrier, and this widget is the second consumer the flag always had.
const showFullscreenButton = Boolean(richField?.mobile_fullscreen);
// Resolved once and given to BOTH renderings of the editor (objectui#3402) —
// exactly like `formatLabel` / `hint` / `placeholder` below, and for the same
// reason. Landing it on the inline surface alone left a disabled rich-text
// field greyed out next to a live expand button whose dialog committed any
// edit through `onCommit`. `disabled` also carries the form's `isSubmitting`.
const disabled = Boolean(props.disabled);

// Resolved once and handed to BOTH renderings of the editor, so the dialog
// cannot drift into showing different copy than the inline surface.
Expand All@@ -137,7 +143,7 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
hint={hint}
placeholder={placeholder}
rows={rows}
disabled={readonly || props.disabled}
disabled={readonly || disabled}
error={error}
className={props.className}
overlay={
Expand All@@ -147,14 +153,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
onCommit={onChange}
label={richField?.label}
testIdPrefix="richtext"
disabled={disabled}
>
{(draft, setDraft) => (
{(draft, setDraft, editorDisabled) => (
<RichTextEditorSurface
value={draft}
onChange={setDraft}
formatLabel={formatLabel}
hint={hint}
placeholder={placeholder}
disabled={editorDisabled}
autoFocus
fullHeight
textareaTestId="richtext-fullscreen-input"
Expand Down
12 changes: 10 additions & 2 deletions packages/fields/src/widgets/TextAreaField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,12 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);

const domProps = toDomProps(props);
// Resolved once and given to BOTH editing surfaces (objectui#3402). It used
// to reach the inline `<Textarea>` alone, so a disabled field greyed out
// correctly while its expand button stayed live and the dialog wrote the edit
// straight back through `onCommit`. `disabled` also carries the form's
// `isSubmitting`, so that hole was open for the duration of every submit.
const disabled = Boolean(domProps.disabled);

return (
<div className="relative">
Expand All@@ -65,7 +71,7 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={textareaField?.placeholder}
disabled={readonly || domProps.disabled}
disabled={readonly || disabled}
rows={rows}
maxLength={maxLength}
aria-invalid={!!error}
Expand All@@ -87,6 +93,7 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
onCommit={onChange}
label={textareaField?.label}
testIdPrefix="textarea"
disabled={disabled}
footer={(draft) =>
maxLength ? (
<span className="text-xs text-muted-foreground self-center">
Expand All@@ -95,11 +102,12 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
) : null
}
>
{(draft, setDraft) => (
{(draft, setDraft, editorDisabled) => (
<Textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={editorDisabled}
maxLength={maxLength}
placeholder={textareaField?.placeholder}
className="h-full min-h-full resize-none text-base"
Expand Down
Loading
Loading