Skip to content
Open
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
104 changes: 95 additions & 9 deletions apps/storybook/stories/RichTextEditor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ const meta: Meta<typeof RichTextEditor> = {
hasMarkdownShortcuts: {control: 'boolean'},
hasAutoFocus: {control: 'boolean'},
maxLength: {control: 'number'},
minHeight: {
control: 'text',
description:
'Minimum height of the editable surface (number in pixels or CSS length).',
},
size: {control: 'select', options: ['sm', 'md', 'lg']},
statusVariant: {
control: 'select',
options: ['attached', 'detached', 'tooltip'],
},
},
};

Expand All @@ -49,7 +58,69 @@ export const WithToolbar: Story = {
args: {
label: 'Notes',
placeholder: 'Format with the toolbar above…',
plugins: <RichTextEditorToolbar />,
toolbar: <RichTextEditorToolbar />,
},
};

export const ResponsiveToolbar: Story = {
name: 'Responsive toolbar',
render: args => (
<div
style={{
width: 420,
minWidth: 280,
maxWidth: '100%',
resize: 'horizontal',
overflow: 'hidden',
}}>
<RichTextEditor {...args} />
</div>
),
args: {
label: 'Notes',
description: 'Resize the editor to test the horizontal toolbar scroll.',
placeholder: 'Every formatting action stays directly available…',
toolbar: <RichTextEditorToolbar />,
},
};

export const ResponsiveToolbarStressTest: Story = {
name: 'Responsive toolbar — stress test',
render: args => {
const [width, setWidth] = useState(420);

return (
<div style={{display: 'grid', gap: 16}}>
<label
style={{
display: 'grid',
gap: 8,
maxWidth: 560,
font: 'inherit',
}}>
<span>Editor width: {width}px</span>
<input
type="range"
min={240}
max={900}
step={10}
value={width}
aria-label="Editor width"
onChange={event => setWidth(event.currentTarget.valueAsNumber)}
/>
</label>
<div style={{width, maxWidth: '100%'}}>
<RichTextEditor {...args} />
</div>
</div>
);
},
args: {
label: 'Responsive toolbar stress test',
description:
'Sweep from 240px to 900px to stress the horizontal toolbar scroll.',
placeholder: 'Scroll the toolbar and toggle several formats…',
toolbar: <RichTextEditorToolbar />,
},
};

Expand All @@ -60,21 +131,17 @@ export const WithLinks: Story = {
'Select text and press the Link button (or Cmd/Ctrl+K) to add a link…',
// The toolbar's Link button creates new-tab links by default (target/rel
// baked into the node). No extra plugin needed.
plugins: <RichTextEditorToolbar />,
toolbar: <RichTextEditorToolbar />,
},
};

export const WithAutoLink: Story = {
args: {
label: 'Notes',
placeholder: 'Type a URL like https://astryx.dev and it auto-links…',
plugins: (
<>
<RichTextEditorToolbar />
{/* Auto-linkify typed/pasted URLs + emails (open in a new tab). */}
<RichTextEditorAutoLinkPlugin />
</>
),
toolbar: <RichTextEditorToolbar />,
// Auto-linkify typed/pasted URLs + emails (open in a new tab).
plugins: <RichTextEditorAutoLinkPlugin />,
},
};

Expand Down Expand Up @@ -119,6 +186,25 @@ export const ErrorStatus: Story = {
label: 'Notes',
placeholder: 'Write something…',
status: {type: 'error', message: 'This field is required.'},
statusVariant: 'attached',
},
};

export const DetachedStatus: Story = {
args: {
label: 'Notes',
placeholder: 'Write something…',
status: {type: 'warning', message: 'Review this content before saving.'},
statusVariant: 'detached',
},
};

export const TooltipStatus: Story = {
args: {
label: 'Notes',
placeholder: 'Write something…',
status: {type: 'error', message: 'This field is required.'},
statusVariant: 'tooltip',
},
};

Expand Down
33 changes: 27 additions & 6 deletions packages/lab/src/RichTextEditor/RichTextEditor.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export const docs = {
{
name: 'placeholder',
type: 'string',
description: 'Placeholder text shown when the editor is empty.',
description:
'Placeholder text shown when the editor is empty. Uses the same responsive body typography and text inset as the editable content.',
},
{
name: 'isReadOnly',
Expand All @@ -69,7 +70,14 @@ export const docs = {
name: 'status',
type: '{ type: "warning" | "error" | "success"; message?: string }',
description:
'Status indicator. Shows a colored border and an optional message below the editor.',
'Validation status. Shows a colored border and status icon; an optional message follows the statusVariant placement. Error also sets aria-invalid.',
},
{
name: 'statusVariant',
type: "'attached' | 'detached' | 'tooltip'",
description:
'How the status is presented: attached keeps the icon in the editor and overlaps the message below; detached floats the message below with its own icon; tooltip hides the message box and reveals it from the focusable in-editor status icon.',
default: "'attached'",
},
{
name: 'size',
Expand All @@ -83,11 +91,17 @@ export const docs = {
description:
'Additional Lexical nodes to register beyond the default OSS set (Heading, Quote, List, Link, Code). Extension point for custom nodes (mentions, images) without forking.',
},
{
name: 'toolbar',
type: 'ReactNode',
description:
'Toolbar content rendered edge-to-edge at the top of the field, before the padded editing surface. Pass RichTextEditorToolbar here for correct visual and keyboard order.',
},
{
name: 'plugins',
type: 'ReactNode',
description:
'Additional Lexical plugins rendered inside the composer. Compose toolbars, mentions, autolink, etc. on top of the base editor.',
'Additional Lexical plugins rendered inside the composer. Compose mentions, autolink, and other editor behavior on top of the base editor.',
},
{
name: 'hasMarkdownShortcuts',
Expand Down Expand Up @@ -128,6 +142,13 @@ export const docs = {
description:
'Width of the field. Numbers are pixels, strings used as-is (e.g. "100%").',
},
{
name: 'minHeight',
type: 'SizeValue',
description:
'Minimum height of the editable content surface. Numbers are pixels; strings are used as CSS lengths. Content continues growing beyond this height.',
default: "'4.5rem'",
},
{
name: 'xstyle',
type: 'StyleXStyles',
Expand All @@ -140,7 +161,7 @@ export const docs = {
},
usage: {
description:
'A WYSIWYG rich-text editor built on Lexical, styled with Astryx design tokens. Experimental component in @astryxdesign/lab (canary). lexical and @lexical/* are optional peer dependencies. The editor is deliberately minimal and extensible: pass nodes and plugins to layer richer behaviour (toolbars, mentions, hover cards) on top without forking. Use RichTextView to render serialized content read-only.',
'A WYSIWYG rich-text editor built on Lexical, styled with Astryx design tokens. Its field container shares TextArea input visuals for the resting border, hover ring, focus-within ring, disabled state, and status colors. Experimental component in @astryxdesign/lab (canary). lexical and @lexical/* are optional peer dependencies. The editor is deliberately minimal and extensible: pass toolbar, nodes, and plugins to layer richer behaviour (formatting, mentions, hover cards) on top without forking. Use RichTextView to render serialized content read-only.',
bestPractices: [
{
guidance: true,
Expand Down Expand Up @@ -170,12 +191,12 @@ export const docs = {
{
guidance: true,
description:
'Add a formatting toolbar by rendering RichTextEditorToolbar in the plugins slot: plugins={<RichTextEditorToolbar />}. It is built from Astryx Toolbar/ToggleButton primitives (so it matches the theme), syncs active states to the selection, and covers bold/italic/underline/strikethrough/code, links, headings, quote, lists, and undo/redo. Compose extra controls via its endContent prop.',
'Add a formatting toolbar with toolbar={<RichTextEditorToolbar />}. The dedicated slot places it edge-to-edge at the top of the field, before the padded editing surface, with correct keyboard order. It uses small Astryx Toolbar controls: undo/redo, a divided block-format Selector for paragraphs/headings/lists/quotes, then divided inline ToggleButtons for bold/italic/underline/strikethrough/code and links. The complete action row scrolls horizontally when space is tight, keeping every control directly available without a More menu. The Selector keeps its default adaptive placement. Compose extra controls via endContent.',
},
{
guidance: true,
description:
'Links: the toolbar Link button (on by default; disable with hasLink={false}) and Cmd/Ctrl+K toggle a link on the selection via Lexical TOGGLE_LINK_COMMAND. It prompts for a URL with window.prompt by default; pass promptForUrl to plug in a custom prompt (e.g. an Astryx Dialog or floating popover). Entered URLs are sanitized (only http/https/mailto/tel are written; javascript:/data: are rejected). Links open in a new tab by default: target=_blank and rel=noopener noreferrer are written into the link node data (so they serialize and round-trip), not patched onto the DOM; set linkOpensInNewTab={false} for same-tab links. Pressing the button while a link is selected removes it.',
'Links: the toolbar Link button (on by default; disable with hasLink={false}) and Cmd/Ctrl+K open an Astryx Dialog. The form preserves the Lexical selection while focus moves into the URL input and supports add, update, remove, Escape, and focus return. Pass promptForUrl only when integrating an existing synchronous URL flow. Entered URLs are sanitized (only http/https/mailto/tel are written; javascript:/data: are rejected). Links open in a new tab by default: target=_blank and rel=noopener noreferrer are written into the link node data; set linkOpensInNewTab={false} for same-tab links.',
},
{
guidance: true,
Expand Down
Loading
Loading