Skip to content

Rich Text TextArea Primitive

A single TipTap-backed TextArea in @kwilo/ui replaces the plain <textarea> primitive and the forked web-app copy. It keeps the same name, auto-expands by default, makes the toolbar opt-in, and offers a plain mode that strips formatting for chat and short fields. Every role with a text input uses it.

How it works

One primitive, one name, one place. Consumers swap with a single prop-signature change: onChange(e)onChange(value). An RHF TextAreaRHF Controller wrapper covers form usage.

Behavior:

  • mode='rich' (default) emits HTML via editor.getHTML(); the toolbar shows per the toolbar prop.
  • mode='plain' emits plain text via editor.getText(); toolbar suppressed; paste-as-plain.
  • autoGrow=true (default) grows the editor box with content between minRows and maxRows, then scrolls.
  • maxLength renders a counter, warning at 90%, error at 100%.
  • Disabled, label, hint, error, and aria-* mirror the existing TextArea primitive.

Stack: TipTap 3.23.4 (already in apps/web), StarterKit + Placeholder + Underline + Link + CharacterCount, added as peerDependencies of @kwilo/ui. react-hook-form 7.74 is also a peer.

API

type TTextAreaProps = {
  value: string                            // HTML when rich, plain text when plain
  onChange: (value: string) => void
  onBlur?: () => void
  onKeyDown?: (e: KeyboardEvent) => void
  label?: string
  error?: string
  hint?: string
  placeholder?: string
  minRows?: number          // default 2
  maxRows?: number          // default 12
  maxLength?: number
  autoGrow?: boolean        // default true
  disabled?: boolean
  required?: boolean
  toolbar?: 'none' | 'minimal' | 'full'  // default 'minimal'
  mode?: 'plain' | 'rich'                // default 'rich'
  id?: string
  name?: string
  className?: string
  autoFocus?: boolean
}

TextAreaRHF is a thin Controller wrapper. Props: name, control, rules, plus all TextArea props minus value/onChange/onBlur.

Notes

  • Long-form rich fields start storing HTML. Backend TEXT columns accept it today, but a sanitization pass is needed before render to prevent XSS. Tracked separately.
  • Homework long-answer learner submissions default to plain (less attack surface); trainer-authored questions and explanations default to rich.
  • Out of scope: migrating ContentEditor / NotionEditor themselves, AI inline suggestions, switching to Lexical, markdown serialization, and apps/site (dep-lean, no TipTap).

Where it lives

  • packages/ui/src/primitives/TextArea.tsx: the primitive (replaces the plain version and the forked apps/web/src/components/ui/TextArea.tsx)
  • Early consumers: apps/web/src/pages/shared/AITutorPage/components/ChatInput/index.tsx, apps/web/src/components/messaging/MessageInput.tsx, and the six apps/web/src/components/homework/QuestionForms/*QuestionForm.tsx