From c64864b3b5eef9a12019b8d645287880eca568cf Mon Sep 17 00:00:00 2001 From: Maofeng Date: Sat, 8 Aug 2026 21:13:49 +0800 Subject: [PATCH] feat(lexical): support styling declarative actions Accept className on action declarations and carry it through compiled actions, default toolbar controls, menu items, custom renderers, and custom controls. Keep preset action ordering consistent across toolbar and bubble placements, with coverage for default and custom rendering paths. --- packages/lexical/src/action-declarations.tsx | 21 +++++----- packages/lexical/src/actions-view.test.tsx | 42 +++++++++++++++++++- packages/lexical/src/actions-view.tsx | 9 +++-- packages/lexical/src/control.test.tsx | 28 ++++++++++++- packages/lexical/src/control.tsx | 4 +- packages/lexical/src/types.ts | 2 + 6 files changed, 90 insertions(+), 16 deletions(-) diff --git a/packages/lexical/src/action-declarations.tsx b/packages/lexical/src/action-declarations.tsx index a135c20..32ff40e 100644 --- a/packages/lexical/src/action-declarations.tsx +++ b/packages/lexical/src/action-declarations.tsx @@ -58,6 +58,7 @@ export type LexicalActionPlacement = export interface LexicalActionProps { children?: (props: LexicalControlRenderProps) => ReactNode + className?: string in?: LexicalActionPlacement } @@ -86,6 +87,7 @@ export type LexicalActionsProps = export interface CompiledLexicalAction { action: AnyLexicalActionDefinition + className?: string key: string kind: "action" render?: AnyLexicalActionProps["children"] @@ -229,6 +231,11 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode { <> + + + + + - - - - - ) @@ -277,6 +279,9 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode { + + + - - - - + ) } @@ -418,6 +420,7 @@ function compileItems( for (const area of new Set(areas)) { output[area].push({ action, + className: props.className, key, kind: "action", render: props.children, diff --git a/packages/lexical/src/actions-view.test.tsx b/packages/lexical/src/actions-view.test.tsx index 918106a..e18b42e 100644 --- a/packages/lexical/src/actions-view.test.tsx +++ b/packages/lexical/src/actions-view.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react" +import { cleanup, fireEvent, render, screen } from "@testing-library/react" import { afterEach, describe, expect, it } from "vitest" import { @@ -54,6 +54,46 @@ describe("declarative Lexical actions", () => { expect(screen.getByRole("button", { name: "格式" })).not.toBeNull() }) + it("forwards an action className to its default control", () => { + render( + undefined}> + + + + + + + ) + + expect( + screen + .getByRole("button", { name: "Bold" }) + .classList.contains("text-destructive") + ).toBe(true) + }) + + it("forwards an action className to its default menu item", async () => { + render( + undefined}> + + + + + + + + + ) + + fireEvent.click(screen.getByRole("button", { name: "格式" })) + + expect( + ( + await screen.findByRole("menuitem", { name: "Bold" }) + ).classList.contains("text-destructive") + ).toBe(true) + }) + it("does not render optional regions without matching actions", () => { render( undefined}> diff --git a/packages/lexical/src/actions-view.tsx b/packages/lexical/src/actions-view.tsx index 59d5f70..39a5979 100644 --- a/packages/lexical/src/actions-view.tsx +++ b/packages/lexical/src/actions-view.tsx @@ -29,6 +29,7 @@ export function LexicalActionTree({ ) : item.type === "menu" ? ( @@ -102,13 +103,14 @@ function MenuItems({ items }: { items: readonly CompiledLexicalActionItem[] }) { } function ActionMenuItem({ item }: { item: CompiledLexicalAction }) { - const { action, render } = item + const { action, className: actionClassName, render } = item const Icon = action.icon if (render || action.control) { return ( @@ -118,10 +120,11 @@ function ActionMenuItem({ item }: { item: CompiledLexicalAction }) { return ( ( + className={actionClassName} + render={({ active, className, disabled, label, onClick }) => ( event.preventDefault()} onClick={onClick} > diff --git a/packages/lexical/src/control.test.tsx b/packages/lexical/src/control.test.tsx index c12efe5..c8f3942 100644 --- a/packages/lexical/src/control.test.tsx +++ b/packages/lexical/src/control.test.tsx @@ -19,8 +19,8 @@ describe("Lexical action rendering", () => { undefined}> - {({ active, label, onClick }) => ( - )} @@ -34,4 +34,28 @@ describe("Lexical action rendering", () => { const button = screen.getByRole("button", { name: "Bold:off" }) expect(() => fireEvent.click(button)).not.toThrow() }) + + it("passes an action className to custom renderers", () => { + render( + undefined}> + + + {({ className, label, onClick }) => ( + + )} + + + + + + ) + + expect( + screen + .getByRole("button", { name: "Bold" }) + .classList.contains("custom-bold") + ).toBe(true) + }) }) diff --git a/packages/lexical/src/control.tsx b/packages/lexical/src/control.tsx index eb0d493..d02d830 100644 --- a/packages/lexical/src/control.tsx +++ b/packages/lexical/src/control.tsx @@ -4,13 +4,13 @@ import { TooltipContent, TooltipTrigger, } from "@workspace/ui/components/tooltip" - import { useLexicalActionContext } from "./context" import { useLexicalMessage } from "./i18n" import type { LexicalControlProps } from "./types" export function LexicalControl({ action, + className, label, presentation = "control", render, @@ -25,6 +25,7 @@ export function LexicalControl({ const onClick = () => execute() const renderProps = { active, + className, disabled, execute, label: resolvedLabel, @@ -54,6 +55,7 @@ export function LexicalControl({