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
46 changes: 46 additions & 0 deletions .changeset/8438-richtext-maxlength-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-form': patch
---

An authored `max_length` on a rich-content field is now VISIBLE, not only enforced at
submit (objectui#8438).

**The defect.** `markdown`, `html` and `richtext` are three registry keys served by ONE
widget, `RichTextField`. That widget read `maxLength` / `max_length` nowhere, while
`buildValidationRules` — which has no field-type gate — compiled the same key into a
react-hook-form rule for every field. So a cap authored on any of the three was enforced
when the form was submitted and invisible before then: no native stop, no character
counter, nothing named in `aria-describedby`. The person was told the limit only after
writing the text, which is the worst of the three possible orderings.

**The fix, and where it is NOT.** The card was filed as "`richtext` is missing from
`ObjectForm`'s maxLength guard and `EmbeddableForm`'s `DEFAULT_MAX_LENGTH`". Re-measured,
neither list could have carried the cap:

- `ObjectForm`'s guard writes `formField.maxLength`, but a registered widget's metadata
carrier is `formField.field` — a different object. Ablating that assignment entirely
changed no rendered attribute, for any of the four types it names. It is left in place
(it is live for the other form-field producer) with the measurement recorded at the site.
- `EmbeddableForm`'s `DEFAULT_MAX_LENGTH` did deliver 5000 for `markdown` and `html`, and
`RichTextField` then dropped it unread.

⇒ The cap was lost for **all three** rich-content keys, not for `richtext` alone.
`RichTextField` now dual-reads `maxLength ?? max_length` off its metadata carrier — the
same read `TextAreaField` has carried since framework#1878 §3 — and forwards it to the
native stop, the `CharacterCount` counter and the `aria-describedby` wiring, on both the
inline surface and the fullscreen dialog.

**What changes for you.** A `markdown`, `html` or `richtext` field that already declares
`max_length` (or the spec-canonical `maxLength`) now shows a counter and stops typing at
the cap, where before it silently accepted the overflow and failed on submit. A field with
no authored cap is unchanged. In `EmbeddableForm`, a public form's `richtext` field is now
capped at the 5000-character long-text default like its two siblings, instead of accepting
unbounded input.

**New export.** `@object-ui/fields` publishes `RICH_TEXT_FIELD_TYPES` (and the
`RichTextFieldType` union), the key set of the widget's display table, so consumers stop
hand-writing the list. `EmbeddableForm`'s cap table is derived from it. This answers the
list question objectui#4831 raised and its fix declined to remove — the root cause behind
objectui#4250, objectui#4831 and this card: a hand-written list that stops at two of one
widget's three registry keys can no longer omit the third, because it no longer names one.
4 changes: 4 additions & 0 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2522,6 +2522,10 @@ export function ColorSwatchCellRenderer({ value }: CellRendererProps): React.Rea
* the widget's readonly branch read.
*/
export { MarkdownCellRenderer, HtmlCellRenderer } from './widgets/richTextDisplay.js';
// The KEY SET of that same table, published for the form-side consumers that
// used to hand-write it (objectui#8438). See its docblock for why a runtime
// list is needed next to the `RichTextFieldType` union.
export { RICH_TEXT_FIELD_TYPES, type RichTextFieldType } from './widgets/richTextDisplay.js';
import { RICH_TEXT_CELL_RENDERERS } from './widgets/richTextDisplay.js';

/**
Expand Down
155 changes: 153 additions & 2 deletions packages/fields/src/widgets/RichTextField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import React, { useId } from 'react';
import type { HtmlFieldMetadata, MarkdownFieldMetadata } from '@object-ui/types';
import { cn, Textarea, EmptyValue, type FullscreenEditorAria } from '@object-ui/components';
import { cn, Textarea, EmptyValue, CharacterCount, type FullscreenEditorAria } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/react';
import { FullscreenFieldEditor } from './FullscreenFieldEditor.js';
import { FieldWidgetComponentProps } from './types.js';
Expand Down Expand Up @@ -38,6 +38,9 @@ function RichTextEditorSurface({
autoFocus,
textareaTestId,
overlay,
counter,
maxLength,
describedBy,
domProps,
editorAria,
}: {
Expand All @@ -55,6 +58,37 @@ function RichTextEditorSurface({
textareaTestId?: string;
/** Absolutely-positioned children over the textarea (the expand affordance). */
overlay?: React.ReactNode;
/**
* The character counter for THIS surface, already constructed by the caller.
*
* Passed in rather than built here for the same reason `formatLabel` and
* `hint` are: the two surfaces differ in exactly one declared behaviour
* (`announceNearLimit`), and the difference belongs at the one place that
* knows which surface it is rendering. Positioned inside the `relative`
* wrapper below, over the textarea, exactly like {@link overlay}.
*/
counter?: React.ReactNode;
/**
* The authored ceiling, forwarded to the `<Textarea>` as the native
* `maxLength` stop (objectui#8438).
*
* ⚠️ It is NOT reachable through {@link domProps}: `maxLength` is not on the
* `toDomProps` whitelist, and this widget read the key nowhere else — so
* before objectui#8438 a `max_length` authored on ANY of the three registry
* keys this widget serves reached no element at all, while the sibling
* `TextAreaField` had honoured it since framework#1878 §3.
*/
maxLength?: number;
/**
* The composed `aria-describedby` for this surface's textarea.
*
* Composed by the CALLER and assigned after the `domProps` spread, because
* the two surfaces compose it from different sources: the inline one appends
* the counter's description id to the ids `<FormControl>` handed down, and
* the dialog's names only its own — the host's ids sit outside the modal,
* which Radix `aria-hidden`s while it is open.
*/
describedBy?: string;
/**
* The host's DOM pass-through (objectui#4810), already filtered through the
* `toDomProps` whitelist by the caller — the field's `id`, the
Expand Down Expand Up @@ -109,6 +143,7 @@ function RichTextEditorSurface({
placeholder={placeholder}
disabled={disabled}
rows={fullHeight ? undefined : rows}
maxLength={maxLength}
// `text-base` in the dialog for the same reason `TextAreaField` uses
// it there: sub-16px inputs make iOS Safari zoom on focus, which is
// exactly wrong for a surface the user opened to get more room.
Expand All @@ -128,13 +163,20 @@ function RichTextEditorSurface({
// keeps that entry passing instead of handing the state back to a
// host that may not have one.
aria-invalid={!!error}
// After the spread so the COMPOSED value wins over the raw
// `aria-describedby` `toDomProps` forwarded — appended, never
// assigned, on the inline surface (see `describedBy`'s caller).
// Overwriting the host's ids would trade "no cap announced" for "no
// error announced", which is strictly worse and silent.
aria-describedby={describedBy}
// LAST, and only ever non-empty on the dialog rendering: inside the
// modal the primitive is the authority on both the name and the
// validation state, and its `aria-invalid` must win over the `!!error`
// above (which is the INLINE channel and is `false` there by
// construction). On the inline surface this spreads nothing.
{...editorAria}
/>
{counter}
{overlay}
</div>
</div>
Expand Down Expand Up @@ -245,6 +287,20 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
const fieldType = resolveRichTextFieldType(field);
const syntax = richTextSyntax(fieldType);

/**
* Two description ids off one `useId()` — one per editing surface, minted
* ABOVE the readonly early return because a hook may not sit behind a
* conditional return. The readonly branch renders no counter, so they go
* unused there; moving the call down would desync hook order the moment a
* field toggles readonly. Identical to `TextAreaField`, and for the same
* reason: the dialog edits a LOCAL draft, so the moment the user types in it
* the two surfaces are counting different strings and one shared id would
* point both textareas at whichever sentence rendered last.
*/
const instanceId = useId();
const descriptionId = `${instanceId}-charcount`;
const fullscreenDescriptionId = `${instanceId}-fullscreen-charcount`;

if (readonly) {
const Display = richTextCellRenderer(fieldType);
// Not a rich-content type: nothing to render as markup, and `prose` would
Expand Down Expand Up @@ -292,6 +348,38 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
// (which is what retired the `as any` that used to launder this carrier).
const richField = field as MarkdownFieldMetadata | HtmlFieldMetadata;
const rows = richField?.rows || 8;
/**
* The authored ceiling — the same dual read `TextAreaField` has carried
* since framework#1878 §3, and `max_length` is declared on all three of this
* widget's metadata faces (`MarkdownFieldMetadata`, `HtmlFieldMetadata`,
* `RichtextFieldMetadata`), so the cast above already admits it.
*
* ## Why this read did not exist until objectui#8438
*
* It was believed to be somebody else's job, in two places that measurement
* says were never doing it:
*
* - `ObjectForm` sets `formField.maxLength` for a hand-written list of
* types. For an object-schema-derived field that assignment lands on the
* FORM FIELD, while the metadata carrier registered widgets read is
* `formField.field` — a different object — so no registered widget has
* ever seen it. Ablating the assignment entirely changes no rendered
* attribute on either the registered or the builtin path.
* - `EmbeddableForm`'s `DEFAULT_MAX_LENGTH` caps `markdown` and `html` at
* 5000. There the form field IS the carrier, so the value did arrive —
* and then died here, unread.
*
* ⇒ The cap was invisible for ALL THREE registry keys, not just `richtext`:
* no native stop, no counter, and nothing named in `aria-describedby`, while
* `buildValidationRules` (which has no field-type gate) rejected the same
* text at SUBMIT. That is the worst ordering of the three possible ones —
* the person is told after writing — and it is what this read ends.
*
* The camelCase half stays a narrow structural read for the same reason it
* does in `TextAreaField`: the objectui metadata types deliberately do not
* declare the spec spelling.
*/
const maxLength = (field as { maxLength?: number }).maxLength ?? richField?.max_length;
// The stored syntax, DERIVED from the type's display pipeline rather than
// read off a `format` key no rich-content type declares. Empty for a type
// with no pipeline: the header names a syntax or it names nothing, it does
Expand All @@ -314,6 +402,17 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
// edit through `onCommit`. `disabled` also carries the form's `isSubmitting`.
const disabled = Boolean(domProps.disabled);

/**
* APPENDED, never assigned. `<FormControl>` is a Radix Slot and has already
* handed this control an `aria-describedby` naming the field's description
* and error message; it arrives through `toDomProps`' `aria-*` pass-through.
* Overwriting it would trade "no cap announced" for "no error announced".
*/
const describedBy =
[domProps['aria-describedby'], maxLength ? descriptionId : undefined]
.filter(Boolean)
.join(' ') || undefined;

// Resolved once and handed to BOTH renderings of the editor, so the dialog
// cannot drift into showing different copy than the inline surface.
const formatLabel = t('fields.richText.format', { format, defaultValue: `Format: ${format}` });
Expand All @@ -333,7 +432,28 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
disabled={readonly || disabled}
error={error}
className={domProps.className}
maxLength={maxLength}
describedBy={describedBy}
domProps={domProps}
counter={
maxLength ? (
/*
The INLINE surface's counter — `announceNearLimit`, exactly as
`TextAreaField`'s is: this is the surface the user types into with
the rest of the form around them, so the near-limit warning is the
one thing worth interrupting for, and `CharacterCount` gates and
debounces it.
*/
<CharacterCount
length={(value || '').length}
maxLength={maxLength}
descriptionId={descriptionId}
announceNearLimit
className="absolute bottom-2 right-2 text-xs text-gray-400"
testId="richtext-character-count"
/>
) : null
}
overlay={
showFullscreenButton && (
<FullscreenFieldEditor
Expand All @@ -351,6 +471,27 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
`aria-hidden` for as long as this dialog is open.
*/
error={error}
/*
The FULLSCREEN surface's counter, with `announceNearLimit={false}`
— objectui#3417's ruling, adopted here unchanged rather than
re-decided: a fullscreen modal is opened deliberately to write at
length, the description already delivers the cap on focus, and
the dialog's textarea carries the same native stop. A second live
region would also put two of them in one document, since the
inline surface stays mounted behind the overlay.
*/
footer={(draft) =>
maxLength ? (
<CharacterCount
length={draft.length}
maxLength={maxLength}
descriptionId={fullscreenDescriptionId}
announceNearLimit={false}
className="text-xs text-muted-foreground self-center"
testId="richtext-fullscreen-character-count"
/>
) : null
}
>
{(draft, setDraft, editorDisabled, editorAria) => (
<RichTextEditorSurface
Expand All @@ -362,6 +503,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
disabled={editorDisabled}
autoFocus
fullHeight
maxLength={maxLength}
/*
ASSIGNED here, not appended — and that is a statement about
this element rather than a relaxation of the inline rule.
`domProps` never reaches the dialog copy (see `domProps`), and
the ids the host would have supplied name nodes OUTSIDE the
modal, which Radix `aria-hidden`s while it is open. There is
nothing to preserve.
*/
describedBy={maxLength ? fullscreenDescriptionId : undefined}
textareaTestId="richtext-fullscreen-input"
editorAria={editorAria}
/>
Expand Down
27 changes: 27 additions & 0 deletions packages/fields/src/widgets/richTextDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,33 @@ export const RICH_TEXT_CELL_RENDERERS = {
/** The rich-content field types, i.e. the keys of {@link RICH_TEXT_CELL_RENDERERS}. */
export type RichTextFieldType = keyof typeof RICH_TEXT_CELL_RENDERERS;

/**
* The same three types as a RUNTIME list, DERIVED from THE table rather than
* spelled a second time — the type-level {@link RichTextFieldType} cannot be
* enumerated at runtime, and a hand-written array beside it would be exactly
* the shape this export exists to retire.
*
* ## Why this export exists
*
* objectui#4831 asked, in its own body, whether the hand-written type lists
* that keep omitting the third of this widget's three registry keys should
* become "every type that resolves to the long-text widget". It was answered
* by adding one literal, and the omission recurred (objectui#4250,
* objectui#8438). This is that question answered in the other direction: a
* consumer that spreads this list cannot omit a key, because it never names
* one. Adding a fourth key to {@link RICH_TEXT_CELL_RENDERERS} extends every
* such consumer in the same commit that adds the key.
*
* ⚠️ It is NOT a general "long text" list and must not be used as one:
* `textarea` renders a `<Textarea>` too and is deliberately absent, because it
* is a different widget with its own registry key and its own metadata face.
* The invariant this list states is narrower and exact — *these are the keys
* `RichTextField` serves*.
*/
export const RICH_TEXT_FIELD_TYPES: readonly RichTextFieldType[] = Object.keys(
RICH_TEXT_CELL_RENDERERS,
) as RichTextFieldType[];

/**
* The SYNTAX a rich-content type stores, derived from the renderer that type
* resolves to rather than declared a second time.
Expand Down
35 changes: 30 additions & 5 deletions packages/plugin-form/src/EmbeddableForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import type { DataSource, FormField } from '@object-ui/types';
import { Button } from '@object-ui/components';
import { CheckCircle2, Lock, Loader2, ShieldCheck } from 'lucide-react';
import { RICH_TEXT_FIELD_TYPES } from '@object-ui/fields';
import { ObjectForm } from './ObjectForm';
import {
useThankYouRedirectNavigation,
Expand Down Expand Up @@ -143,16 +144,40 @@ export interface EmbeddableFormProps {
className?: string;
}

/** Hardened default caps applied to text-shaped customFields when the spec
* doesn't already define one. Mirrors Airtable/Tally defaults. */
/** The long-form cap, shared by `textarea` and every rich-content type. */
const LONG_TEXT_MAX_LENGTH = 5000;

/**
* Hardened default caps applied to text-shaped customFields when the spec
* doesn't already define one. Mirrors Airtable/Tally defaults.
*
* ## The rich-content entries are DERIVED, not enumerated (objectui#8438)
*
* `markdown` and `html` used to be written out here while `richtext` — the
* THIRD registry key of the same one widget — was absent, so a public form's
* `richtext` field took unbounded input. That is the fourth instance of a
* root cause objectui#4831 named in its own body and its fix declined to
* remove: *hand-written type lists that stop at two of this widget's three
* keys* (objectui#4250 and objectui#4831 are the other two).
*
* Spreading {@link RICH_TEXT_FIELD_TYPES} is that question answered rather
* than deferred again: this table no longer names a rich-content type, so it
* can no longer omit one, and a fourth key added to the widget's display table
* arrives here in the same commit that adds it.
*
* ⛔ The four short-text entries above stay literal ON PURPOSE. Each is a
* distinct widget with a distinct cap, and there is no table to derive them
* from — a predicate wide enough to cover them would be an invention, not a
* derivation. The root cause being removed is "one widget, N keys, a list that
* knows N-1", which is a statement about the rich-content trio alone.
*/
const DEFAULT_MAX_LENGTH: Record<string, number> = {
text: 200,
email: 254, // RFC 5321
url: 2048,
phone: 32,
textarea: 5000,
markdown: 5000,
html: 5000,
textarea: LONG_TEXT_MAX_LENGTH,
...Object.fromEntries(RICH_TEXT_FIELD_TYPES.map((t) => [t, LONG_TEXT_MAX_LENGTH])),
};

const DEFAULT_HONEYPOT_NAME = '_company_website_2';
Expand Down
Loading
Loading