Skip to content

Commit a321fa4

Browse files
refactor(fields): TextAreaField reads mobile_fullscreen from one source (#3232) (#3244)
The widget resolved its fullscreen affordance through a four-way `??` chain: a `mobileFullscreen` (camelCase) prop, `field.mobile_fullscreen`, a `mobile_fullscreen` prop, and `schema.mobile_fullscreen`. Three of the four were permanently `undefined`: - `mobileFullscreen` had no producer anywhere in the repo — the only two occurrences of that spelling were this widget's own read and the destructure that kept it off the DOM spread. The doc comment claimed "the host form passes `mobileFullscreen`", so it documented a contract that never held. - `mobile_fullscreen` as a prop cannot arrive: `stripRegisteredFieldProps` (components/src/renderers/form/form.tsx) removes it, and `fullscreen`, from the props forwarded to registered field widgets. - `schema.mobile_fullscreen` restates the object `field || schema` already resolves into `textareaField`. What is left is the one live source: the field metadata flag ObjectForm stamps onto long-text fields from `ObjectFormSchema.mobile.fullscreenLongText`. The doc comment now describes that, names the producer, and records why there is deliberately no host-override prop. No behaviour change. The metadata path is pinned by new tests covering the button, the dialog and the committed edit, so the cleanup cannot have removed the working affordance; the retired spellings are pinned inert at compile time (they are not on the closed `FieldWidgetComponentProps`, #3221) and at runtime. The built-in (unregistered) `textarea` branch of the form renderer, which reads `mobile_fullscreen || fullscreen` off the form-field props and renders its own `FullscreenTextarea`, is a separate live path and is untouched. Per AGENTS.md #0.1 and Prime Directive #12: divergence converges at the producer, not by accumulating tolerance at the consumer. Fixes #3232 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5dcec82 commit a321fa4

3 files changed

Lines changed: 233 additions & 14 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@object-ui/fields": patch
3+
---
4+
5+
`TextAreaField`'s mobile fullscreen flag converges on its one real producer
6+
(objectui#3232).
7+
8+
FROM: the widget resolved the "show the expand affordance" decision through a
9+
four-way `??` chain — a `mobileFullscreen` (camelCase) prop, the field
10+
metadata's `mobile_fullscreen`, a `mobile_fullscreen` prop, and
11+
`schema.mobile_fullscreen`. TO: a single read of the field metadata's
12+
`mobile_fullscreen`, resolved through the `field || schema` carrier pair every
13+
widget in this package already uses.
14+
15+
No runtime behaviour changes, because three of those four reads were
16+
permanently `undefined`:
17+
18+
- `mobileFullscreen` (camelCase) had **no producer anywhere in the repo** — the
19+
only occurrences of that spelling were the widget's own read and the
20+
destructure that kept it off the DOM spread. The doc comment nonetheless
21+
claimed "the host form passes `mobileFullscreen`", so the contract it
22+
described had never held.
23+
- `mobile_fullscreen` as a **prop** cannot arrive: the form renderer's
24+
`stripRegisteredFieldProps` explicitly removes `mobile_fullscreen` and
25+
`fullscreen` from the props forwarded to registered field widgets.
26+
- `schema.mobile_fullscreen` was the same object `field || schema` already
27+
resolves, so it could only ever restate the metadata read.
28+
29+
What actually drives the affordance — and is now the only thing that does — is
30+
the field metadata flag `ObjectForm` stamps onto long-text fields from
31+
`ObjectFormSchema.mobile.fullscreenLongText`. That path is unchanged and is now
32+
pinned by tests (button, dialog, and the committed edit), so the cleanup cannot
33+
have silently removed the working behaviour.
34+
35+
Also untouched: the built-in (unregistered) `textarea` branch of the form
36+
renderer, which reads `mobile_fullscreen || fullscreen` off the form-field
37+
props and renders its own `FullscreenTextarea`. That is a separate live path.
38+
39+
Why this is worth a changeset rather than a silent tidy-up: reads that nobody
40+
writes are not free. They document a contract that does not exist — the next
41+
author follows the comment, passes the prop, and is ignored without a word —
42+
and a `??` chain that accepts four spellings and rejects none is exactly where
43+
a misspelled key hides. With one source, a wrong spelling has no read path left
44+
to absorb it. Per AGENTS.md #0.1 and Prime Directive #12, divergence like this
45+
converges at the producer, not by accumulating tolerance at the consumer. No
46+
host-override prop was invented in its place: inventing a key with no producer
47+
is the same mistake in the other direction.

packages/fields/src/widgets/TextAreaField.tsx

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,26 @@ import { FieldWidgetComponentProps } from './types';
1616
* TextAreaField - Multi-line text input widget
1717
* Supports configurable row count and preserves whitespace in readonly mode.
1818
*
19-
* Mobile UX (round 3): when the host form passes `mobileFullscreen` (or the
20-
* field schema sets `mobile_fullscreen: true`), an "expand" affordance opens
21-
* a fullscreen edit dialog — much easier on phones than tapping a 4-row
22-
* textarea trapped between other fields.
19+
* Mobile UX (round 3): when the FIELD METADATA carries `mobile_fullscreen:
20+
* true`, an "expand" affordance opens a fullscreen edit dialog — much easier
21+
* on phones than tapping a 4-row textarea trapped between other fields.
22+
*
23+
* That flag has exactly one producer: `ObjectForm` stamps it onto every
24+
* long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is set
25+
* (`plugin-form/src/ObjectForm.tsx`). It reaches this widget on `field`, or
26+
* on `schema` when the host is `SchemaRenderer` — the same pair every widget
27+
* here resolves as `field || schema` (see `FieldWidgetComponentProps.schema`).
28+
*
29+
* There is deliberately NO widget-prop override. A `mobileFullscreen`
30+
* (camelCase) prop was read here and written by nobody in the repo, and the
31+
* snake_case `mobile_fullscreen` prop cannot arrive either: the form renderer
32+
* strips both `mobile_fullscreen` and `fullscreen` from the props it forwards
33+
* to registered widgets (`stripRegisteredFieldProps` in
34+
* `components/src/renderers/form/form.tsx`). Reading keys nobody produces
35+
* documented a contract that never held and invited the next author to pass a
36+
* silently-ignored prop, so the reads are gone (objectui#3232). If a host
37+
* override is ever genuinely needed, declare ONE key on
38+
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
2339
*/
2440
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
2541
// Hooks must run before any early return (readonly) to keep hook order stable.
@@ -40,21 +56,17 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
4056
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
4157
// spec-authored maxLength gave neither the textarea cap nor the counter.
4258
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
43-
// Mobile fullscreen flag may arrive on the field metadata, on the form-field
44-
// schema (when called via the form renderer where `field` is the ObjectQL
45-
// metadata sub-object), or as an explicit widget prop.
46-
const showFullscreenButton = Boolean(
47-
(props as any).mobileFullscreen ??
48-
textareaField?.mobile_fullscreen ??
49-
(props as any).mobile_fullscreen ??
50-
(props as any).schema?.mobile_fullscreen,
51-
);
59+
// Mobile fullscreen opt-in travels on the field metadata and nowhere else.
60+
// `textareaField` already resolves the two carriers a host may use for that
61+
// metadata (`field`, else `schema`), so this is a single read — a misspelled
62+
// flag now has no read path to quietly catch it.
63+
const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);
5264

5365
const openFullscreen = () => { setDraft(value ?? ''); setFullscreenOpen(true); };
5466
const cancelFullscreen = () => setFullscreenOpen(false);
5567
const commitFullscreen = () => { onChange(draft); setFullscreenOpen(false); };
5668

57-
const { inputType, mobileFullscreen, ...domProps } = props as any;
69+
const { inputType, ...domProps } = props as any;
5870

5971
return (
6072
<div className="relative">
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
* `TextAreaField`'s fullscreen affordance has ONE source (objectui#3232).
11+
*
12+
* It used to be read from four places with a `??` chain — a `mobileFullscreen`
13+
* (camelCase) prop, `field.mobile_fullscreen`, a `mobile_fullscreen` prop, and
14+
* `schema.mobile_fullscreen`. Only the field-metadata flag was ever produced:
15+
* `ObjectForm` stamps it on long-text fields from
16+
* `ObjectFormSchema.mobile.fullscreenLongText`. The camelCase prop had no
17+
* producer anywhere in the repo, and the snake_case prop could not arrive
18+
* because `stripRegisteredFieldProps` (components/src/renderers/form/form.tsx)
19+
* removes it from what registered widgets are forwarded.
20+
*
21+
* So this file pins both halves of that convergence:
22+
*
23+
* 1. **The live path still works.** The metadata flag drives the affordance
24+
* end to end — button, dialog, and the committed edit. Deleting the dead
25+
* reads must not have cost the working behaviour, and this is the test
26+
* that fails if a future refactor drops the real read too.
27+
* 2. **The retired prop spellings are gone**, at compile time (they are not
28+
* on the closed `FieldWidgetComponentProps`, objectui#3221) and at runtime
29+
* (a host that passes one anyway gets no affordance). Convergence means a
30+
* misspelled flag is now inert and loud, not silently absorbed.
31+
*
32+
* Note the built-in (unregistered) `textarea` branch in `form.tsx` reads
33+
* `mobile_fullscreen || fullscreen` off the form-field props and renders its
34+
* own `FullscreenTextarea`. That is a separate, live path and is untouched
35+
* here — this file is about the registered `field:textarea` widget only.
36+
*/
37+
38+
import { describe, it, expect, vi } from 'vitest';
39+
import { render, screen, fireEvent } from '@testing-library/react';
40+
import '@testing-library/jest-dom';
41+
42+
import { TextAreaField } from '../TextAreaField';
43+
import type { FieldWidgetComponentProps } from '../types';
44+
import type { FieldMetadata } from '@object-ui/types';
45+
46+
const fieldMeta = (extra: Record<string, unknown> = {}) =>
47+
({
48+
name: 'description',
49+
label: 'Description',
50+
type: 'textarea',
51+
...extra,
52+
}) as unknown as FieldMetadata;
53+
54+
describe('TextAreaField mobile fullscreen — the metadata flag is the only source', () => {
55+
it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
56+
render(
57+
<TextAreaField
58+
value="hello"
59+
onChange={() => {}}
60+
field={fieldMeta({ mobile_fullscreen: true })}
61+
/>,
62+
);
63+
64+
expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
65+
});
66+
67+
it('opens the fullscreen dialog and commits the edited draft', () => {
68+
const onChange = vi.fn();
69+
render(
70+
<TextAreaField
71+
value="hello"
72+
onChange={onChange}
73+
field={fieldMeta({ mobile_fullscreen: true })}
74+
/>,
75+
);
76+
77+
// Closed until the affordance is used — the dialog is not just mounted.
78+
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
79+
80+
fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
81+
expect(screen.getByTestId('textarea-fullscreen-dialog')).toBeInTheDocument();
82+
83+
const dialogInput = screen.getByTestId('textarea-fullscreen-input');
84+
expect(dialogInput).toHaveValue('hello');
85+
86+
fireEvent.change(dialogInput, { target: { value: 'hello from fullscreen' } });
87+
// The draft is local until "Done" — the host is not notified per keystroke.
88+
expect(onChange).not.toHaveBeenCalled();
89+
90+
fireEvent.click(screen.getByTestId('textarea-fullscreen-save'));
91+
expect(onChange).toHaveBeenCalledWith('hello from fullscreen');
92+
});
93+
94+
it('reads the flag off `schema` when the host supplies no `field`', () => {
95+
// `SchemaRenderer` passes the authored node as `schema`; the widget
96+
// resolves its config as `field || schema`, which is why deleting the
97+
// separate `schema.mobile_fullscreen` read lost nothing. `field` is
98+
// required by the type, so the cast reproduces the runtime shape only.
99+
render(
100+
<TextAreaField
101+
value=""
102+
onChange={() => {}}
103+
field={undefined as unknown as FieldMetadata}
104+
schema={fieldMeta({ mobile_fullscreen: true })}
105+
/>,
106+
);
107+
108+
expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
109+
});
110+
111+
it('renders no affordance when the metadata does not opt in', () => {
112+
render(<TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} />);
113+
114+
expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
115+
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
116+
});
117+
});
118+
119+
describe('TextAreaField mobile fullscreen — the retired prop spellings', () => {
120+
it('does not declare them on the widget contract', () => {
121+
const props = {} as FieldWidgetComponentProps<string>;
122+
123+
// Both were read by the widget and written by no host. With the closed
124+
// props type (objectui#3221) they are now compile errors, so a future
125+
// author reaches for the metadata flag instead of a silently ignored prop.
126+
// @ts-expect-error `mobileFullscreen` is not part of this contract
127+
void props.mobileFullscreen;
128+
// @ts-expect-error `mobile_fullscreen` is a metadata key, not a widget prop
129+
void props.mobile_fullscreen;
130+
131+
expect(true).toBe(true);
132+
});
133+
134+
it('ignores them at runtime when a host passes them anyway', () => {
135+
// Untyped hosts (plain JS, `as any` spreads) can still get these onto the
136+
// element. They must not resurrect the affordance: the metadata flag is
137+
// the contract. Unknown props land on the DOM spread, so React's unknown
138+
// -attribute warnings are expected noise here and are suppressed.
139+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
140+
try {
141+
const hostProps = {
142+
mobileFullscreen: true,
143+
mobile_fullscreen: true,
144+
} as unknown as Partial<FieldWidgetComponentProps<string>>;
145+
146+
render(
147+
<TextAreaField
148+
value="hello"
149+
onChange={() => {}}
150+
field={fieldMeta()}
151+
{...hostProps}
152+
/>,
153+
);
154+
155+
expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
156+
} finally {
157+
consoleError.mockRestore();
158+
}
159+
});
160+
});

0 commit comments

Comments
 (0)