---
name: aircall-blocks/migrate-dashboard/rich-textarea
description: >
  Migrate @dashboard/library TagHighlightTextarea to the @aircall/ds RichTextarea. Covers
  the #tag colored-pill mapping, the controlled string value -> uncontrolled defaultValue +
  onValueChange({ plainText }) shift, and the props RichTextarea does not yet cover (maxLength
  counter, onBlur/onFocus). Load when a file imports TagHighlightTextarea from
  @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/ds/src/components/rich-textarea.tsx"
  - "aircall/hydra:packages/ds/src/index.ts"
---

This skill builds on aircall-blocks/migrate-dashboard.

`TagHighlightTextarea` is a TipTap multi-line textarea that highlights `#tag` mentions as
colored pills and reports its content as a plain-text string. The @aircall/ds `RichTextarea`
(Plate.js) covers the same `#tag` pill behavior natively via its `tags` prop, so the swap
is mostly a prop remap plus one behavioral shift: RichTextarea is **uncontrolled** and
reports `{ plainText, json }` rather than a controlled string.

## 1. Component mapping

| @dashboard/library `TagHighlightTextarea` | @aircall/ds `RichTextarea` |
| --- | --- |
| `TagHighlightTextarea` (root) | `RichTextarea` (root, all-in-one) |
| `tags: CompanyTag[]` (`{ id, name, color }`, `color` = hex) | `tags: TagOption[]` (`{ id, label, color? }`) — `name` -> `label`; hex `color` -> DS `Badge` semantic color (see gap 2) |
| `value: string` (controlled) | `defaultValue: string` (uncontrolled — set once at mount; see gap 1) |
| `onChange: (value: string) => void` | `onValueChange: ({ plainText }) => void` — call your handler with `plainText` |
| `placeholder?: string` | `placeholder?: string` (1:1) |
| Multi-line (Enter inserts a newline) | `submitOnEnter={false}` — RichTextarea defaults to Enter-submits; a textarea must opt out so Enter is a newline (Shift+Enter also inserts one) |
| `maxLength?` + the `n/max` counter | **No built-in equivalent** — enforce and render at the consumer level (see gap 3) |
| `onBlur?` / `onFocus?` | **Not supported** by RichTextarea props (see gap 4) |
| The `#` tag suggestion menu (colored rows) | Built in — typing `#` opens the tag menu filtered over `tags`; selection inserts a colored `Badge` pill |
| free-typed unknown `#tag` | RichTextarea offers a "Create" row for free text; the created tag has no `id`. If you must restrict to known tags only, filter it out in your `onValueChange` handler |

## 2. Imports

```tsx
import { RichTextarea, type TagOption } from '@aircall/ds';
```

Drop the TipTap stack entirely — `@tiptap/*`, `@xstyled/styled-components`, the local
`createSuggestionRenderer` / `createTagMentionExtension` / `parseInitialContent` /
`SuggestionList` helpers, and the `EditorWrapper` styled component are all replaced by the
single `RichTextarea` import. `getPlainText` is replaced by the `plainText` field of
`onValueChange`.

## 3. Before / after

Before (`@dashboard/library`):

```tsx
<TagHighlightTextarea
  value={note}
  onChange={setNote}
  tags={companyTags}            // { id, name, color: '#0662B5' }[]
  placeholder="Add a note..."
  maxLength={280}
  onBlur={handleBlur}
/>
```

After (`@aircall/ds`):

```tsx
const tags: TagOption[] = companyTags.map(t => ({
  id: t.id,
  label: t.name,
  color: toBadgeColor(t.color), // hex -> DS Badge semantic color (gap 2)
}));

<RichTextarea
  tags={tags}
  defaultValue={note}                 // uncontrolled: initial value only (gap 1)
  submitOnEnter={false}               // textarea: Enter = newline
  placeholder="Add a note..."
  aria-label="Note"                   // placeholder is not an accessible name
  onValueChange={({ plainText }) => setNote(plainText)}
  className="min-h-[150px]"           // match the old 150px min-height if needed
/>
```

## 4. Gaps and caveats (read before migrating)

1. **Controlled -> uncontrolled.** RichTextarea seeds from `defaultValue` only at mount and
   never re-reads it. `TagHighlightTextarea` synced its editor when `value` changed
   externally (form reset/undo); RichTextarea does not. Drive your state from `onValueChange`.
   To force a reset (form reset, switching records), remount by changing `key`:
   `<RichTextarea key={recordId} defaultValue={initialNote} … />`. Do NOT feed `plainText`
   back in as `defaultValue` every render expecting it to update — it won't.

2. **Tag color fidelity.** `CompanyTag.color` is an arbitrary hex, mapped in the legacy
   component to the nearest palette swatch. `TagOption.color` is a DS `Badge` **semantic**
   color (not a hex), so exact hex colors are not preserved. Map each hex to the nearest DS
   `Badge` color (reuse your existing nearest-swatch logic, or a small lookup). DS `Badge`
   supports `legacyColor` for raw hex, but `RichTextarea`'s `TagOption` does not expose it
   yet — if exact hex pills are a hard requirement, file a DS ticket to add `legacyColor`
   passthrough to `TagOption` rather than blocking the migration.

3. **`maxLength` + counter.** RichTextarea has no `maxLength` or character counter. Track it
   in the consumer: count `plainText.length` in `onValueChange`, render your own counter, and
   enforce the limit (e.g. ignore updates once over the limit, or trim). Do not assume the
   component will cap input.

4. **`onBlur` / `onFocus`.** RichTextarea does not expose these props. If you need them,
   attach handlers to the wrapping element (`onBlurCapture` / `onFocusCapture` on a parent
   `div`), or file a DS ticket to add them. Do not invent props that are not in the type.

5. **Plain text keeps tag labels.** `onValueChange`'s `plainText` includes the `#tag` labels
   (e.g. `"Call @ 9am about #billing"`), matching `getPlainText`. Use `plainText`, not
   `json`, when the backend expects the legacy string.

## 5. Verify

- `tsc --noEmit` — the callback changed from `onChange(string)` to
  `onValueChange({ plainText })`; fix every call site.
- Confirm no `@tiptap/*` / `@xstyled/*` imports remain in the migrated file.
- Manually: type text, `#` opens the tag menu, a picked tag renders as a colored pill, and
  your `onValueChange` receives the plain-text string. Enter inserts a newline (not submit).
- Tests: DS popups need the jsdom shims from `@aircall/ds#aircall-ds/setup`.
