Skip to content

feat(editor): add rich text editor toolbar#3173

Draft
jpzwarte wants to merge 8 commits into
mainfrom
claude/add-editor-toolbar-OQAwK
Draft

feat(editor): add rich text editor toolbar#3173
jpzwarte wants to merge 8 commits into
mainfrom
claude/add-editor-toolbar-OQAwK

Conversation

@jpzwarte
Copy link
Copy Markdown
Member

@jpzwarte jpzwarte commented Apr 3, 2026

Add <sl-editor-toolbar> component that renders a formatting toolbar
inside <sl-editor> using <sl-tool-bar> and <sl-button>.

Toolbar groups:

  • Text marks: Bold, Italic, Underline, Strikethrough, Inline code
  • Block formats: Blockquote, Heading 1–3
  • Lists: Bullet list, Ordered list
  • History: Undo, Redo (disabled when unavailable)

Buttons reflect the current editor selection state via aria-pressed
and fill="outline" when active. The toolbar is schema-aware — buttons
only render when the corresponding mark/node is in the editor schema.

Set toolbar=false on <sl-editor> to hide the toolbar.

https://claude.ai/code/session_01D6g9VibYCJSMMmMp1y5qgm

Add `<sl-editor-toolbar>` component that renders a formatting toolbar
inside `<sl-editor>` using `<sl-tool-bar>` and `<sl-button>`.

Toolbar groups:
- Text marks: Bold, Italic, Underline, Strikethrough, Inline code
- Block formats: Blockquote, Heading 1–3
- Lists: Bullet list, Ordered list
- History: Undo, Redo (disabled when unavailable)

Buttons reflect the current editor selection state via `aria-pressed`
and `fill="outline"` when active. The toolbar is schema-aware — buttons
only render when the corresponding mark/node is in the editor schema.

Set `toolbar=false` on `<sl-editor>` to hide the toolbar.

https://claude.ai/code/session_01D6g9VibYCJSMMmMp1y5qgm
Copilot AI review requested due to automatic review settings April 3, 2026 20:31
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 3, 2026

🦋 Changeset detected

Latest commit: 4c9b235

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

💥 An error occurred when fetching the changed packages and changesets in this PR
Some errors occurred when validating the changesets config:
The package "@sl-design-system/grid" depends on the ignored package "@sl-design-system/example-data", but "@sl-design-system/grid" is not being ignored. Please add "@sl-design-system/grid" to the `ignore` option.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new rich text editor toolbar component (<sl-editor-toolbar>) and integrates it into <sl-editor> (enabled by default, hideable via toolbar=false) to expose common formatting actions that reflect selection state.

Changes:

  • Introduces <sl-editor-toolbar> built on <sl-tool-bar>/<sl-button> with schema-aware button rendering and undo/redo support.
  • Integrates the toolbar into <sl-editor> and adds a toolbar boolean property to toggle visibility.
  • Adds styling, Storybook controls/stories, registration/exports, and a new spec file for the toolbar.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/components/editor/src/editor.ts Adds toolbar integration, scoped element registration, and transaction-to-toolbar state syncing.
packages/components/editor/src/editor.stories.ts Adds a toolbar control and new stories (NoToolbar/Empty), refactors sample content.
packages/components/editor/src/editor.scss Updates layout to accommodate toolbar above the editor mount.
packages/components/editor/src/editor-toolbar.ts New toolbar component implementing formatting actions and active-state UI.
packages/components/editor/src/editor-toolbar.spec.ts New tests covering rendering, disabled/hidden states, and basic interactions.
packages/components/editor/src/editor-toolbar.scss Adds toolbar border/padding styling.
packages/components/editor/register.ts Registers sl-editor-toolbar alongside sl-editor.
packages/components/editor/package.json Adds dependencies needed by the toolbar and scoped elements usage.
packages/components/editor/index.ts Exports the new toolbar class.
.changeset/add-editor-toolbar.md Documents the new toolbar feature and API surface.

Comment on lines 82 to 88
override firstUpdated(): void {
this.view ??= this.createEditor();

if (this.toolbarElement) {
this.toolbarElement.view = this.view;
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If toolbar is initially false and later toggled to true, the newly-rendered <sl-editor-toolbar> never receives view, so it will render without any schema/buttons and won’t sync state. Consider setting toolbarElement.view = this.view whenever the toolbar is (re)created (e.g. in updated() when toolbar changes or when toolbarElement becomes defined), not only in firstUpdated().

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +178
#isNodeActive(nodeType: NodeType, attrs?: Record<string, unknown>): boolean {
const state = this.view?.state;
if (!state || !nodeType) return false;

const { from, to } = state.selection;
let found = false;

state.doc.nodesBetween(from, to, (node: PMNode) => {
if (node.type === nodeType) {
if (!attrs || Object.entries(attrs).every(([key, val]) => node.attrs[key] === val)) {
found = true;
}
}
});

return found;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#isNodeActive() relies on state.doc.nodesBetween(from, to, ...). For a collapsed selection (from === to), nodesBetween won’t visit any nodes, which means node-based formats (blockquote/headings/lists) will never be reported as active when the cursor is inside them. Handle state.selection.empty explicitly (e.g. inspect $from.parent / ancestor chain) so active state works without requiring a range selection.

Copilot uses AI. Check for mistakes.
claude and others added 2 commits April 3, 2026 20:47
…nly with tooltips

Registers custom Lucide-style SVG icons (editor-bold, editor-italic,
editor-underline, editor-strikethrough) and renders them via sl-icon
inside sl-button. The tooltip directive provides accessible labels
via aria-labelledby. Adds @sl-design-system/icon and
@sl-design-system/tooltip as dependencies.

https://claude.ai/code/session_01D6g9VibYCJSMMmMp1y5qgm
Copilot AI review requested due to automatic review settings April 4, 2026 07:18
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 4, 2026

🕸 Website preview

You can view a preview here (commit 4c9b235141172dcbdf3c6af5e67b118fc9992a45).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment on lines 115 to 126
const editor = new EditorView(
{ mount },
{
state,
dispatchTransaction: function (tr) {
// `this` is bound to the view instance.
(this as unknown as EditorView).updateState(this.state.apply(tr));
dispatchTransaction: (tr) => {
editor.updateState(editor.state.apply(tr));

// Notify toolbar so it can sync active-state of buttons
if (this.toolbarElement) {
this.toolbarElement.editorState = editor.state;
}
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatchTransaction closes over editor while editor is still being constructed. If ProseMirror (or a plugin) dispatches during new EditorView(...), this can hit the TDZ and throw at runtime. Prefer the previous pattern that uses function (tr) { /* this bound to view */ }, or assign the view to a let first and reference that from the callback.

Copilot uses AI. Check for mistakes.
@property({ attribute: false }) plugins?: Plugin[];

/** Whether to show the formatting toolbar. Defaults to true. */
@property({ type: Boolean }) toolbar = true;
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toolbar is implemented as a Boolean attribute/property, but in HTML an attribute like toolbar="false" is still treated as truthy by Lit’s Boolean converter. If the intended API is to support toolbar=false (as mentioned in the PR description), you’ll need a custom converter (e.g. treat the string "false" as false) or switch to an inverted attribute such as no-toolbar. Otherwise, please update docs/examples to use property binding (e.g. .toolbar=${false}) and avoid implying toolbar="false" works.

Suggested change
@property({ type: Boolean }) toolbar = true;
@property({
converter: {
fromAttribute: (value: string | null) => value !== null && value.toLowerCase() !== 'false'
}
})
toolbar = true;

Copilot uses AI. Check for mistakes.
@jpzwarte jpzwarte marked this pull request as draft April 4, 2026 11:42
Copilot AI review requested due to automatic review settings April 7, 2026 07:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.

Comment on lines +193 to +209
#isNodeActive(nodeType: NodeType, attrs?: Record<string, unknown>): boolean {
const state = this.view?.state;
if (!state || !nodeType) return false;

const { from, to } = state.selection;
let found = false;

state.doc.nodesBetween(from, to, (node: PMNode) => {
if (node.type === nodeType) {
if (!attrs || Object.entries(attrs).every(([key, val]) => node.attrs[key] === val)) {
found = true;
}
}
});

return found;
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#isNodeActive() uses doc.nodesBetween(from, to, ...) which will not visit any nodes for an empty (cursor) selection (from === to). This makes block-level buttons (blockquote/headings/lists) never show as active for the common cursor case, and can also cause toggle logic to behave incorrectly. Handle the empty-selection case by checking the selection’s $from.parent / $from.node(...) chain (and matching attrs), or use a helper that detects the current block type at the cursor position.

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +248
lift(state, view.dispatch);
} else {
wrapIn(schema.nodes.blockquote)(state, view.dispatch);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Blockquote action uses wrapIn(schema.nodes.blockquote) (and lift to undo), but the editor schema defines blockquote with content: 'inline*' (a textblock), so wrapping a paragraph selection in a blockquote is typically not applicable and the command will no-op. If blockquote is intended as a block format (per PR description), either change the schema so blockquote can wrap blocks (e.g. content: 'block+') or switch the command to setBlockType(schema.nodes.blockquote) (and revert to paragraph) to match the current schema.

Suggested change
lift(state, view.dispatch);
} else {
wrapIn(schema.nodes.blockquote)(state, view.dispatch);
setBlockType(schema.nodes.paragraph)(state, view.dispatch);
} else {
setBlockType(schema.nodes.blockquote)(state, view.dispatch);

Copilot uses AI. Check for mistakes.
Comment on lines +262 to +275
case 'bullet-list':
if (this.#isNodeActive(schema.nodes.bulletList)) {
liftListItem(schema.nodes.listItem)(state, view.dispatch);
} else {
wrapInList(schema.nodes.bulletList)(state, view.dispatch);
}
break;
case 'ordered-list':
if (this.#isNodeActive(schema.nodes.orderedList)) {
liftListItem(schema.nodes.listItem)(state, view.dispatch);
} else {
wrapInList(schema.nodes.orderedList)(state, view.dispatch);
}
break;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List toggling currently uses wrapInList() and liftListItem() directly and relies on #isNodeActive() to decide which path to take. Given the cursor-selection issue in #isNodeActive(), clicking list buttons while inside an existing list can incorrectly wrap/nest instead of toggling off. Consider reusing the existing list helpers in this package (e.g. toggleUnorderedList / toggleOrderedList in list-utils.ts), which already implement proper on/off behavior and lifting semantics for this schema.

Copilot uses AI. Check for mistakes.
Comment on lines +223 to +233
it('should keep paragraph block when Blockquote action is not applicable', async () => {
const view = editor.view!;
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(2))));
await toolbar.updateComplete;

const bqButton = getButtonByIconName('far-quote-left')!;
bqButton.click();
await toolbar.updateComplete;

expect(editor.view?.state.doc.firstChild?.type.name).to.equal('paragraph');
});
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that clicking the Blockquote button does not change the document (expects the first node to remain paragraph). That contradicts the PR description which lists Blockquote as a supported toolbar block format. Once the blockquote command/schema is fixed so the action is applicable, this test should be updated to assert that blockquote is applied/toggled correctly (including the cursor-selection case).

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 7, 2026

🕸 Storybook preview

You can view a preview here (commit 4c9b235141172dcbdf3c6af5e67b118fc9992a45).

Copilot AI review requested due to automatic review settings April 7, 2026 12:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 2 comments.

export default {
title: 'Form/Editor',
tags: ['!dev'],
tags: [/*'!dev',*/ 'draft'],
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a commented-out Storybook tag inside the tags array makes the story metadata harder to read and maintain. Prefer removing the comment and explicitly setting the intended tags (e.g., keep !dev if the story should remain hidden, or drop it entirely if it shouldn’t).

Suggested change
tags: [/*'!dev',*/ 'draft'],
tags: ['draft'],

Copilot uses AI. Check for mistakes.
@@ -5,7 +5,6 @@
"emitDeclarationOnly": false,
"module": "ES2022",
"moduleResolution": "bundler",
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing noPropertyAccessFromIndexSignature reduces TypeScript strictness for the Angular package and can mask unsafe property access. If this was done to unblock a specific error, consider addressing that type issue directly (or scoping the relaxation) to avoid weakening type safety across the package.

Suggested change
"moduleResolution": "bundler",
"moduleResolution": "bundler",
"noPropertyAccessFromIndexSignature": true,

Copilot uses AI. Check for mistakes.
@Diaan
Copy link
Copy Markdown
Collaborator

Diaan commented Apr 17, 2026

closed because we need environments

@Diaan Diaan closed this Apr 17, 2026
@Diaan Diaan reopened this Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants