diff --git a/.changeset/7083-richtext-field-metadata.md b/.changeset/7083-richtext-field-metadata.md new file mode 100644 index 0000000000..85242174ed --- /dev/null +++ b/.changeset/7083-richtext-field-metadata.md @@ -0,0 +1,56 @@ +--- +'@object-ui/types': minor +--- + +`RichtextFieldMetadata` — the third registry key of `RichTextField` becomes declarable +(objectui#7083, maintainer ruling 2026-09-07, director decision batch #71). + +`markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the three +already had an exported metadata type; `richtext` had none, so the runtime served it by +structure while an author could not write its metadata under an annotation at all. The +only way to write one was `as unknown as MarkdownFieldMetadata`, and that deliberate +cast — in this repo's own pin test — was the gap's sole evidence. The state was neither +a union member nor a recorded alias, which is why it had to be rediscovered to be seen. + +**New.** `RichtextFieldMetadata` is exported from `@object-ui/types` and joins the +`FieldMetadata` union, so a richtext field's metadata can be written as a typed literal +and narrowed out of the union on `type`: + +```ts +import type { RichtextFieldMetadata } from '@object-ui/types'; + +const doc: RichtextFieldMetadata = { + type: 'richtext', + name: 'doc', + label: 'Release notes', + rows: 10, + placeholder: 'Write the release notes…', +}; +``` + +**Additive only.** Nothing is removed or narrowed: `richtext` field metadata that was +previously written through a cast keeps compiling, and every other member of the union +is untouched. The one behavioural surface — `RichTextField` — is unchanged; it already +served all three keys and this release only gives the third one a face. + +**The member's shape was derived, not copied from its two siblings.** `type`, `rows`, +`placeholder`, `mobile_fullscreen` and `label` are the keys `RichTextField` actually +reads on the `richtext` path (the last three already sit on `BaseFieldMetadata`, so the +member declares `type` and `rows`); the readonly branch hands the metadata to a cell +renderer that reads `value` only and contributes no key. + +`max_length` is the one declared key the widget itself does not read, and it is declared +because a live reader outside the widget does: `buildValidationRules` compiles +`maxLength ?? max_length` into a react-hook-form rule for **every** field it is handed — +it is generic, with no field-type gate — and both form producers call it on every field +they build. So `max_length` on a `richtext` field is enforced when the form is +submitted. (It is not, however, forwarded to the editor's HTML `maxlength` attribute, +and it gets no default cap in `EmbeddableForm`: both of those enumerate field types and +omit `richtext`. That predates this release and is unchanged by it.) Omitting the key +would have left `richtext` the one type of the three whose ceiling cannot be authored +under an annotation while the submit-time rule enforcing it stayed live — a fresh +instance of the asymmetry this member exists to end. + +Docs: `content/docs/fields/rich-text.mdx` teaches all three metadata types and carries a +`RichtextFieldMetadata` snippet; before this release the page stated there was no third +type. diff --git a/content/docs/fields/rich-text.mdx b/content/docs/fields/rich-text.mdx index f337af863c..8bfe7787d0 100644 --- a/content/docs/fields/rich-text.mdx +++ b/content/docs/fields/rich-text.mdx @@ -14,13 +14,18 @@ The Rich Text Field component edits formatted text content with markdown or HTML ## Field Schema -`markdown` and `html` are two field types served by one widget, and each has its own -exported metadata type — `MarkdownFieldMetadata` and `HtmlFieldMetadata` -(`@object-ui/types`). Both extend `BaseFieldMetadata` and add a length bound and an -inline-editor height; there is no combined "rich text" metadata type. +`markdown`, `html` and `richtext` are three field types served by one widget, and each +has its own exported metadata type — `MarkdownFieldMetadata`, `HtmlFieldMetadata` and +`RichtextFieldMetadata` (`@object-ui/types`). All three extend `BaseFieldMetadata` and +add a length bound and an inline-editor height; there is no combined "rich text" +metadata type. ```ts -import type { HtmlFieldMetadata, MarkdownFieldMetadata } from '@object-ui/types'; +import type { + HtmlFieldMetadata, + MarkdownFieldMetadata, + RichtextFieldMetadata, +} from '@object-ui/types'; const releaseNotes: MarkdownFieldMetadata = { type: 'markdown', @@ -39,15 +44,36 @@ const emailBody: HtmlFieldMetadata = { label: 'Email Body', max_length: 50000, }; + +const articleBody: RichtextFieldMetadata = { + type: 'richtext', + name: 'article_body', + label: 'Article Body', + rows: 10, + max_length: 50000, +}; ``` -`rows` sizes the inline editor, in text rows — declared on both types (and on +`richtext` gained its exported type in +[objectui#7083](https://github.com/objectstack-ai/objectui/issues/7083); until then it +was the one key of the three with no member to annotate against, so its metadata could +only be written through a cast to one of its siblings. It stores **HTML** — it reads +through the same display pipeline as `html`, not the markdown one +([objectui#5452](https://github.com/objectstack-ai/objectui/issues/5452)) — and that +pipeline is the only thing `type` changes between the three. + +`max_length` is a bound on the stored text. The widget itself does not read it; the +form does — `buildValidationRules` compiles it into the submit-time validation rules +for every field, whatever its type — so it is enforced when the form is submitted, not +as a `maxlength` attribute on the rich-text editor. + +`rows` sizes the inline editor, in text rows — declared on all three types (and on `@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the `textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)). The editor is a plain textarea today — there is no formatting toolbar, no preview pane -and no pixel height to configure, so neither metadata type declares one: `toolbar`, -`preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing them -does nothing. +and no pixel height to configure, so none of the three metadata types declares one: +`toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing +them does nothing. The value being edited, and the `className` / `disabled` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). @@ -116,3 +142,8 @@ The rich text field can operate in different modes: - Full HTML editing - Advanced formatting - Embedded content support + +3. **Rich Text Mode** (`type: 'richtext'`) + - Stores HTML, and reads through the same display pipeline as `html` + - Editable under `RichtextFieldMetadata` + - Same plain-textarea editing surface as the other two diff --git a/packages/fields/src/widgets/RichTextField.tsx b/packages/fields/src/widgets/RichTextField.tsx index 598f1a4198..a744d6c772 100644 --- a/packages/fields/src/widgets/RichTextField.tsx +++ b/packages/fields/src/widgets/RichTextField.tsx @@ -280,13 +280,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop return ; } - // The declared metadata face for this widget's registry keys. `markdown` and - // `html` each have an exported type; the third key, `richtext`, has no union - // member of its own and structurally matches the same three optional reads - // below — every key this widget consumes (`rows`, `mobile_fullscreen`, - // `placeholder`, `label`) is DECLARED on both members, `rows` since the - // objectui#6140 Option A ruling (which is what retired the `as any` that - // used to launder this carrier). + // The declared metadata face for this widget's registry keys. All three of + // them have an exported type: `markdown` and `html` always did, and the third + // key, `richtext`, gained `RichtextFieldMetadata` in objectui#7083 — which is + // what retired the deliberate `as unknown as MarkdownFieldMetadata` its pin + // test needed for as long as the union had no branch to write it against. + // The cast below names two of the three because it does not have to + // discriminate: every key this widget consumes (`rows`, `mobile_fullscreen`, + // `placeholder`, `label`) is DECLARED on all three, so the two named already + // admit every read below — `rows` since the objectui#6140 Option A ruling + // (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 stored syntax, DERIVED from the type's display pipeline rather than diff --git a/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx b/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx index 031a8050ca..4757897eb1 100644 --- a/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx +++ b/packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx @@ -15,8 +15,11 @@ * 2026-08-25, Option A, aligning the `TextareaFieldMetadata` precedent), so * the field literals below carry it under the excess-property check rather * than through a cast. The `richtext` registry key resolves to the same - * widget (objectui#5498) with no union member of its own, so its case is the - * one deliberate `as` in this file. + * widget (objectui#5498) and now has a union member of its own too + * (`RichtextFieldMetadata`, objectui#7083), so all three literals here are + * annotated and this file holds no `as` at all — the deliberate cast that used + * to sit on the richtext case WAS the only evidence that the third key had no + * declarable face, and it went with the gap it recorded. * * Direction of the DOM assertion: `rows` lands on the HTML `rows` attribute of * the inline `