diff --git a/.changeset/textarea-mobile-fullscreen-single-source.md b/.changeset/textarea-mobile-fullscreen-single-source.md new file mode 100644 index 0000000000..2a68da3c30 --- /dev/null +++ b/.changeset/textarea-mobile-fullscreen-single-source.md @@ -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. diff --git a/packages/fields/src/widgets/TextAreaField.tsx b/packages/fields/src/widgets/TextAreaField.tsx index 453ac02329..18d2a90f33 100644 --- a/packages/fields/src/widgets/TextAreaField.tsx +++ b/packages/fields/src/widgets/TextAreaField.tsx @@ -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) { // 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, // 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 (
diff --git a/packages/fields/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx b/packages/fields/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx new file mode 100644 index 0000000000..42767c72f8 --- /dev/null +++ b/packages/fields/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx @@ -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 = {}) => + ({ + 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( + {}} + 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( + , + ); + + // 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( + {}} + 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( {}} 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; + + // 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>; + + render( + {}} + field={fieldMeta()} + {...hostProps} + />, + ); + + expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +});