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
47 changes: 47 additions & 0 deletions .changeset/textarea-mobile-fullscreen-single-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@object-ui/fields": patch
---

`TextAreaField`'s mobile fullscreen flag converges on its one real producer
(objectui#3232).

FROM: the widget resolved the "show the expand affordance" decision through a
four-way `??` chain — a `mobileFullscreen` (camelCase) prop, the field
metadata's `mobile_fullscreen`, a `mobile_fullscreen` prop, and
`schema.mobile_fullscreen`. TO: a single read of the field metadata's
`mobile_fullscreen`, resolved through the `field || schema` carrier pair every
widget in this package already uses.

No runtime behaviour changes, because three of those four reads were
permanently `undefined`:

- `mobileFullscreen` (camelCase) had **no producer anywhere in the repo** — the
only occurrences of that spelling were the widget's own read and the
destructure that kept it off the DOM spread. The doc comment nonetheless
claimed "the host form passes `mobileFullscreen`", so the contract it
described had never held.
- `mobile_fullscreen` as a **prop** cannot arrive: the form renderer's
`stripRegisteredFieldProps` explicitly removes `mobile_fullscreen` and
`fullscreen` from the props forwarded to registered field widgets.
- `schema.mobile_fullscreen` was the same object `field || schema` already
resolves, so it could only ever restate the metadata read.

What actually drives the affordance — and is now the only thing that does — is
the field metadata flag `ObjectForm` stamps onto long-text fields from
`ObjectFormSchema.mobile.fullscreenLongText`. That path is unchanged and is now
pinned by tests (button, dialog, and the committed edit), so the cleanup cannot
have silently removed the working behaviour.

Also untouched: 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`. That is a separate live path.

Why this is worth a changeset rather than a silent tidy-up: reads that nobody
writes are not free. They document a contract that does not exist — the next
author follows the comment, passes the prop, and is ignored without a word —
and a `??` chain that accepts four spellings and rejects none is exactly where
a misspelled key hides. With one source, a wrong spelling has no read path left
to absorb it. Per AGENTS.md #0.1 and Prime Directive #12, divergence like this
converges at the producer, not by accumulating tolerance at the consumer. No
host-override prop was invented in its place: inventing a key with no producer
is the same mistake in the other direction.
40 changes: 26 additions & 14 deletions packages/fields/src/widgets/TextAreaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,26 @@ import { FieldWidgetComponentProps } from './types';
* TextAreaField - Multi-line text input widget
* Supports configurable row count and preserves whitespace in readonly mode.
*
* Mobile UX (round 3): when the host form passes `mobileFullscreen` (or the
* field schema sets `mobile_fullscreen: true`), an "expand" affordance opens
* a fullscreen edit dialog — much easier on phones than tapping a 4-row
* textarea trapped between other fields.
* Mobile UX (round 3): when the FIELD METADATA carries `mobile_fullscreen:
* true`, an "expand" affordance opens a fullscreen edit dialog — much easier
* on phones than tapping a 4-row textarea trapped between other fields.
*
* That flag has exactly one producer: `ObjectForm` stamps it onto every
* long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is set
* (`plugin-form/src/ObjectForm.tsx`). It reaches this widget on `field`, or
* on `schema` when the host is `SchemaRenderer` — the same pair every widget
* here resolves as `field || schema` (see `FieldWidgetComponentProps.schema`).
*
* There is deliberately NO widget-prop override. A `mobileFullscreen`
* (camelCase) prop was read here and written by nobody in the repo, and the
* snake_case `mobile_fullscreen` prop cannot arrive either: the form renderer
* strips both `mobile_fullscreen` and `fullscreen` from the props it forwards
* to registered widgets (`stripRegisteredFieldProps` in
* `components/src/renderers/form/form.tsx`). Reading keys nobody produces
* documented a contract that never held and invited the next author to pass a
* silently-ignored prop, so the reads are gone (objectui#3232). If a host
* override is ever genuinely needed, declare ONE key on
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
*/
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
// Hooks must run before any early return (readonly) to keep hook order stable.
Expand All @@ -40,21 +56,17 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
// spec-authored maxLength gave neither the textarea cap nor the counter.
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
// Mobile fullscreen flag may arrive on the field metadata, on the form-field
// schema (when called via the form renderer where `field` is the ObjectQL
// metadata sub-object), or as an explicit widget prop.
const showFullscreenButton = Boolean(
(props as any).mobileFullscreen ??
textareaField?.mobile_fullscreen ??
(props as any).mobile_fullscreen ??
(props as any).schema?.mobile_fullscreen,
);
// Mobile fullscreen opt-in travels on the field metadata and nowhere else.
// `textareaField` already resolves the two carriers a host may use for that
// metadata (`field`, else `schema`), so this is a single read — a misspelled
// flag now has no read path to quietly catch it.
const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);

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

const { inputType, mobileFullscreen, ...domProps } = props as any;
const { inputType, ...domProps } = props as any;

return (
<div className="relative">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `TextAreaField`'s fullscreen affordance has ONE source (objectui#3232).
*
* It used to be read from four places with a `??` chain — a `mobileFullscreen`
* (camelCase) prop, `field.mobile_fullscreen`, a `mobile_fullscreen` prop, and
* `schema.mobile_fullscreen`. Only the field-metadata flag was ever produced:
* `ObjectForm` stamps it on long-text fields from
* `ObjectFormSchema.mobile.fullscreenLongText`. The camelCase prop had no
* producer anywhere in the repo, and the snake_case prop could not arrive
* because `stripRegisteredFieldProps` (components/src/renderers/form/form.tsx)
* removes it from what registered widgets are forwarded.
*
* So this file pins both halves of that convergence:
*
* 1. **The live path still works.** The metadata flag drives the affordance
* end to end — button, dialog, and the committed edit. Deleting the dead
* reads must not have cost the working behaviour, and this is the test
* that fails if a future refactor drops the real read too.
* 2. **The retired prop spellings are gone**, at compile time (they are not
* on the closed `FieldWidgetComponentProps`, objectui#3221) and at runtime
* (a host that passes one anyway gets no affordance). Convergence means a
* misspelled flag is now inert and loud, not silently absorbed.
*
* Note the built-in (unregistered) `textarea` branch in `form.tsx` reads
* `mobile_fullscreen || fullscreen` off the form-field props and renders its
* own `FullscreenTextarea`. That is a separate, live path and is untouched
* here — this file is about the registered `field:textarea` widget only.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';

import { TextAreaField } from '../TextAreaField';
import type { FieldWidgetComponentProps } from '../types';
import type { FieldMetadata } from '@object-ui/types';

const fieldMeta = (extra: Record<string, unknown> = {}) =>
({
name: 'description',
label: 'Description',
type: 'textarea',
...extra,
}) as unknown as FieldMetadata;

describe('TextAreaField mobile fullscreen — the metadata flag is the only source', () => {
it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
render(
<TextAreaField
value="hello"
onChange={() => {}}
field={fieldMeta({ mobile_fullscreen: true })}
/>,
);

expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
});

it('opens the fullscreen dialog and commits the edited draft', () => {
const onChange = vi.fn();
render(
<TextAreaField
value="hello"
onChange={onChange}
field={fieldMeta({ mobile_fullscreen: true })}
/>,
);

// Closed until the affordance is used — the dialog is not just mounted.
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();

fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
expect(screen.getByTestId('textarea-fullscreen-dialog')).toBeInTheDocument();

const dialogInput = screen.getByTestId('textarea-fullscreen-input');
expect(dialogInput).toHaveValue('hello');

fireEvent.change(dialogInput, { target: { value: 'hello from fullscreen' } });
// The draft is local until "Done" — the host is not notified per keystroke.
expect(onChange).not.toHaveBeenCalled();

fireEvent.click(screen.getByTestId('textarea-fullscreen-save'));
expect(onChange).toHaveBeenCalledWith('hello from fullscreen');
});

it('reads the flag off `schema` when the host supplies no `field`', () => {
// `SchemaRenderer` passes the authored node as `schema`; the widget
// resolves its config as `field || schema`, which is why deleting the
// separate `schema.mobile_fullscreen` read lost nothing. `field` is
// required by the type, so the cast reproduces the runtime shape only.
render(
<TextAreaField
value=""
onChange={() => {}}
field={undefined as unknown as FieldMetadata}
schema={fieldMeta({ mobile_fullscreen: true })}
/>,
);

expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
});

it('renders no affordance when the metadata does not opt in', () => {
render(<TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} />);

expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
});
});

describe('TextAreaField mobile fullscreen — the retired prop spellings', () => {
it('does not declare them on the widget contract', () => {
const props = {} as FieldWidgetComponentProps<string>;

// Both were read by the widget and written by no host. With the closed
// props type (objectui#3221) they are now compile errors, so a future
// author reaches for the metadata flag instead of a silently ignored prop.
// @ts-expect-error `mobileFullscreen` is not part of this contract
void props.mobileFullscreen;
// @ts-expect-error `mobile_fullscreen` is a metadata key, not a widget prop
void props.mobile_fullscreen;

expect(true).toBe(true);
});

it('ignores them at runtime when a host passes them anyway', () => {
// Untyped hosts (plain JS, `as any` spreads) can still get these onto the
// element. They must not resurrect the affordance: the metadata flag is
// the contract. Unknown props land on the DOM spread, so React's unknown
// -attribute warnings are expected noise here and are suppressed.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const hostProps = {
mobileFullscreen: true,
mobile_fullscreen: true,
} as unknown as Partial<FieldWidgetComponentProps<string>>;

render(
<TextAreaField
value="hello"
onChange={() => {}}
field={fieldMeta()}
{...hostProps}
/>,
);

expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
} finally {
consoleError.mockRestore();
}
});
});
Loading