From 3b991c926ff77e95c14dda7ea51a9b02555b6f74 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 9 Sep 2026 04:56:11 +0000 Subject: [PATCH] fix(fields): honour an authored max_length on every rich-content field (objectui#8438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `markdown`, `html` and `richtext` are three registry keys served by ONE widget, `RichTextField` — and that widget read `maxLength` / `max_length` nowhere, while `buildValidationRules` (no field-type gate) compiled the same key into a react-hook-form rule for every field. A cap authored on any of the three was therefore enforced at SUBMIT and invisible before then: no native stop, no character counter, nothing named in `aria-describedby`. The card was filed as "`richtext` is missing from ObjectForm's maxLength guard and EmbeddableForm's DEFAULT_MAX_LENGTH". Re-measured on the branch base, 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 changed no rendered attribute for any of its four types, on either the registered or the builtin render path. 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`; `RichTextField` then dropped it unread. So the cap was lost for all three keys, not for `richtext` alone. `RichTextField` now dual-reads `maxLength ?? max_length` off its 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. The list question objectui#4831 asked and its fix declined to answer is answered here: `RICH_TEXT_FIELD_TYPES` is derived from `RICH_TEXT_CELL_RENDERERS` — THE table — and published, and `EmbeddableForm`'s cap table spreads it instead of naming rich-content types. A list that names no member can omit none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .changeset/8438-richtext-maxlength-visible.md | 46 ++++ packages/fields/src/index.tsx | 4 + packages/fields/src/widgets/RichTextField.tsx | 155 ++++++++++++- .../fields/src/widgets/richTextDisplay.tsx | 27 +++ packages/plugin-form/src/EmbeddableForm.tsx | 35 ++- packages/plugin-form/src/ObjectForm.tsx | 27 +++ .../src/richtextMaxLength.8438.test.tsx | 216 ++++++++++++++++++ packages/types/src/field-types.ts | 18 +- 8 files changed, 515 insertions(+), 13 deletions(-) create mode 100644 .changeset/8438-richtext-maxlength-visible.md create mode 100644 packages/plugin-form/src/richtextMaxLength.8438.test.tsx diff --git a/.changeset/8438-richtext-maxlength-visible.md b/.changeset/8438-richtext-maxlength-visible.md new file mode 100644 index 0000000000..a20cbde3cb --- /dev/null +++ b/.changeset/8438-richtext-maxlength-visible.md @@ -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. diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 0d6307af14..febc56d959 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -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'; /** diff --git a/packages/fields/src/widgets/RichTextField.tsx b/packages/fields/src/widgets/RichTextField.tsx index a744d6c772..5e55bb5bc7 100644 --- a/packages/fields/src/widgets/RichTextField.tsx +++ b/packages/fields/src/widgets/RichTextField.tsx @@ -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'; @@ -38,6 +38,9 @@ function RichTextEditorSurface({ autoFocus, textareaTestId, overlay, + counter, + maxLength, + describedBy, domProps, editorAria, }: { @@ -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 `