Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-bats-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/gov-ui-kit': patch
---

Add `valueFormat` prop in `TextAreaRichText` to control `onChange` output as `html`, `markdown` or `text`
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,34 @@ describe('<TextAreaRichText /> component', () => {
expect(textbox.getAttribute('aria-labelledby')).toBeDefined();
});

it('calls the onChange property on input change', async () => {
it('calls the onChange property on input change with the value formatted as serialized HTML by default', async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange }));
await userEvent.type(screen.getByRole('textbox'), 'test');
expect(onChange).toHaveBeenLastCalledWith('<p>test</p>');
});

it("calls the onChange property on input change with plain text when 'valueFormat' is set to 'text'", async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange, valueFormat: 'text' }));
await userEvent.type(screen.getByRole('textbox'), 'test');
expect(onChange).toHaveBeenLastCalledWith('test');
});

it("calls the onChange property on input change with serialized HTML when 'valueFormat' is set to 'html'", async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange, valueFormat: 'html' }));
await userEvent.type(screen.getByRole('textbox'), 'test');
expect(onChange).toHaveBeenLastCalledWith('<p>test</p>');
});

it("calls the onChange property on input change with markdown when 'valueFormat' is set to 'markdown'", async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange, valueFormat: 'markdown' }));
await userEvent.type(screen.getByRole('textbox'), '# test');
expect(onChange).toHaveBeenLastCalledWith('# test');
});

it('defaults to empty string instead of empty paragraph when input is empty', async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange }));
Expand All @@ -62,7 +83,9 @@ describe('<TextAreaRichText /> component', () => {
it('formats pasted markdown content', async () => {
const onChange = jest.fn();
render(createTestComponent({ onChange }));
const event = { getData: (type: string) => (type === 'text/plain' ? '# Heading' : '') } as DataTransfer;
const event = {
getData: (type: string) => (type === 'text/plain' ? '# Heading' : ''),
} as DataTransfer;
await userEvent.click(screen.getByRole('textbox'));
await userEvent.paste(event);
expect(onChange).toHaveBeenLastCalledWith('<h1>Heading</h1>');
Expand Down
57 changes: 51 additions & 6 deletions src/core/components/forms/textAreaRichText/textAreaRichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { StarterKit } from '@tiptap/starter-kit';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Markdown } from 'tiptap-markdown';
import { Markdown, type MarkdownStorage } from 'tiptap-markdown';
import { useRandomId } from '../../../hooks';
import { InputContainer, type IInputContainerProps } from '../inputContainer';
import { type IInputContainerProps, InputContainer } from '../inputContainer';
import { TextAreaRichTextActions } from './textAreaRichTextActions';

export type ValueFormat = 'html' | 'markdown' | 'text';

export interface ITextAreaRichTextProps
extends Omit<IInputContainerProps, 'maxLength' | 'inputLength' | 'value' | 'onChange' | 'id'> {
/**
Expand All @@ -32,6 +34,13 @@ export interface ITextAreaRichTextProps
* Whether to render the editor on the first render or not.
*/
immediatelyRender?: boolean;
/**
* Format of the input value, which determines how content is interpreted and returned.
* Can be serialized HTML, markdown, or plain text.
*
* @default 'html'
*/
valueFormat?: ValueFormat;
}

// Classes to properly style the TipTap placeholder
Expand All @@ -43,15 +52,29 @@ const placeholderClasses = classNames(
);

export const TextAreaRichText: React.FC<ITextAreaRichTextProps> = (props) => {
const { value, onChange, placeholder, disabled, className, id, immediatelyRender, ...containerProps } = props;
const {
value,
onChange,
placeholder,
disabled,
className,
id,
immediatelyRender,
valueFormat = 'html',
...containerProps
} = props;

const [isExpanded, setIsExpanded] = useState(false);

const randomId = useRandomId(id);

const extensions = [
StarterKit,
Placeholder.configure({ placeholder, emptyNodeClass: placeholderClasses, showOnlyWhenEditable: false }),
Placeholder.configure({
placeholder,
emptyNodeClass: placeholderClasses,
showOnlyWhenEditable: false,
}),
Link,
Markdown.configure({ transformPastedText: true }),
];
Expand All @@ -69,7 +92,27 @@ export const TextAreaRichText: React.FC<ITextAreaRichTextProps> = (props) => {
},
},
onUpdate: ({ editor }) => {
const value = editor.getText() !== '' ? editor.getHTML() : '';
if (editor.getText() === '') {
onChange?.('');
return;
}

const handlers: Record<ValueFormat, () => string | undefined> = {
html: () => editor.getHTML(),
text: () => editor.getText(),
markdown: () => {
const markdownStorage = editor.storage.markdown as MarkdownStorage | undefined;

if (markdownStorage) {
return markdownStorage.getMarkdown();
}

return editor.getHTML();
},
};

const value = handlers[valueFormat]() ?? editor.getHTML();

onChange?.(value);
},
});
Expand Down Expand Up @@ -111,7 +154,9 @@ export const TextAreaRichText: React.FC<ITextAreaRichTextProps> = (props) => {
'fixed top-0 left-0 z-[var(--guk-text-area-rich-text-expanded-z-index)] h-screen w-full [&>label]:hidden':
isExpanded,
})}
wrapperClassName={classNames('grow overflow-hidden', { 'rounded-none!': isExpanded })}
wrapperClassName={classNames('grow overflow-hidden', {
'rounded-none!': isExpanded,
})}
id={randomId}
{...containerProps}
>
Expand Down