@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
127 lines (105 loc) • 6.39 kB
Markdown
---
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`.