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.
This commit is contained in:
Maofeng
2026-08-08 21:13:49 +08:00
parent bc84912e48
commit c64864b3b5
6 changed files with 90 additions and 16 deletions
+12 -9
View File
@@ -58,6 +58,7 @@ export type LexicalActionPlacement =
export interface LexicalActionProps<Value = string> { export interface LexicalActionProps<Value = string> {
children?: (props: LexicalControlRenderProps<Value>) => ReactNode children?: (props: LexicalControlRenderProps<Value>) => ReactNode
className?: string
in?: LexicalActionPlacement in?: LexicalActionPlacement
} }
@@ -86,6 +87,7 @@ export type LexicalActionsProps =
export interface CompiledLexicalAction { export interface CompiledLexicalAction {
action: AnyLexicalActionDefinition action: AnyLexicalActionDefinition
className?: string
key: string key: string
kind: "action" kind: "action"
render?: AnyLexicalActionProps["children"] render?: AnyLexicalActionProps["children"]
@@ -229,6 +231,11 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
<> <>
<Undo /> <Undo />
<Redo /> <Redo />
<Bold in={formattingAreas} />
<Italic in={formattingAreas} />
<Underline in={formattingAreas} />
<Link in={formattingAreas} />
<ClearFormatting in={formattingAreas} />
<ActionGroup <ActionGroup
type="menu" type="menu"
label={lexicalMessages.paragraphStyle} label={lexicalMessages.paragraphStyle}
@@ -242,11 +249,6 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
<BulletList /> <BulletList />
<OrderedList /> <OrderedList />
</ActionGroup> </ActionGroup>
<Bold in={formattingAreas} />
<Italic in={formattingAreas} />
<Underline in={formattingAreas} />
<Link in={formattingAreas} />
<ClearFormatting in={formattingAreas} />
</> </>
) )
@@ -277,6 +279,9 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
<Underline in={formattingAreas} /> <Underline in={formattingAreas} />
<Link in={formattingAreas} /> <Link in={formattingAreas} />
<TextColor in={formattingAreas} /> <TextColor in={formattingAreas} />
<Strikethrough in="bubble" />
<Subscript in="bubble" />
<Superscript in="bubble" />
<ActionGroup <ActionGroup
type="menu" type="menu"
label={lexicalMessages.moreFormatting} label={lexicalMessages.moreFormatting}
@@ -289,10 +294,6 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
<Subscript /> <Subscript />
<Superscript /> <Superscript />
</ActionGroup> </ActionGroup>
<Strikethrough in="bubble" />
<Subscript in="bubble" />
<Superscript in="bubble" />
<ClearFormatting in={formattingAreas} />
<ActionGroup <ActionGroup
type="menu" type="menu"
label={lexicalMessages.insert} label={lexicalMessages.insert}
@@ -322,6 +323,7 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
<Indent /> <Indent />
</ActionGroup> </ActionGroup>
</ActionGroup> </ActionGroup>
<ClearFormatting in={formattingAreas} />
</> </>
) )
} }
@@ -418,6 +420,7 @@ function compileItems(
for (const area of new Set(areas)) { for (const area of new Set(areas)) {
output[area].push({ output[area].push({
action, action,
className: props.className,
key, key,
kind: "action", kind: "action",
render: props.children, render: props.children,
+41 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @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 { afterEach, describe, expect, it } from "vitest"
import { import {
@@ -54,6 +54,46 @@ describe("declarative Lexical actions", () => {
expect(screen.getByRole("button", { name: "格式" })).not.toBeNull() expect(screen.getByRole("button", { name: "格式" })).not.toBeNull()
}) })
it("forwards an action className to its default control", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold className="text-destructive" />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
expect(
screen
.getByRole("button", { name: "Bold" })
.classList.contains("text-destructive")
).toBe(true)
})
it("forwards an action className to its default menu item", async () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ActionGroup type="menu" label="格式">
<Bold className="text-destructive" />
</ActionGroup>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
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", () => { it("does not render optional regions without matching actions", () => {
render( render(
<LexicalRoot value="" onChange={() => undefined}> <LexicalRoot value="" onChange={() => undefined}>
+6 -3
View File
@@ -29,6 +29,7 @@ export function LexicalActionTree({
<LexicalControl <LexicalControl
key={item.key} key={item.key}
action={item.action} action={item.action}
className={item.className}
render={item.render} render={item.render}
/> />
) : item.type === "menu" ? ( ) : item.type === "menu" ? (
@@ -102,13 +103,14 @@ function MenuItems({ items }: { items: readonly CompiledLexicalActionItem[] }) {
} }
function ActionMenuItem({ item }: { item: CompiledLexicalAction }) { function ActionMenuItem({ item }: { item: CompiledLexicalAction }) {
const { action, render } = item const { action, className: actionClassName, render } = item
const Icon = action.icon const Icon = action.icon
if (render || action.control) { if (render || action.control) {
return ( return (
<LexicalControl <LexicalControl
action={action} action={action}
className={actionClassName}
presentation="menu-item" presentation="menu-item"
render={render} render={render}
/> />
@@ -118,10 +120,11 @@ function ActionMenuItem({ item }: { item: CompiledLexicalAction }) {
return ( return (
<LexicalControl <LexicalControl
action={action} action={action}
render={({ active, disabled, label, onClick }) => ( className={actionClassName}
render={({ active, className, disabled, label, onClick }) => (
<DropdownMenuItem <DropdownMenuItem
disabled={disabled} disabled={disabled}
className={cn(active && "bg-muted")} className={cn(active && "bg-muted", className)}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={onClick} onClick={onClick}
> >
+26 -2
View File
@@ -19,8 +19,8 @@ describe("Lexical action rendering", () => {
<LexicalRoot value="" onChange={() => undefined}> <LexicalRoot value="" onChange={() => undefined}>
<LexicalActions> <LexicalActions>
<Bold> <Bold>
{({ active, label, onClick }) => ( {({ active, className, label, onClick }) => (
<button type="button" onClick={onClick}> <button type="button" className={className} onClick={onClick}>
{label}:{active ? "on" : "off"} {label}:{active ? "on" : "off"}
</button> </button>
)} )}
@@ -34,4 +34,28 @@ describe("Lexical action rendering", () => {
const button = screen.getByRole("button", { name: "Bold:off" }) const button = screen.getByRole("button", { name: "Bold:off" })
expect(() => fireEvent.click(button)).not.toThrow() expect(() => fireEvent.click(button)).not.toThrow()
}) })
it("passes an action className to custom renderers", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<Bold className="custom-bold">
{({ className, label, onClick }) => (
<button type="button" className={className} onClick={onClick}>
{label}
</button>
)}
</Bold>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
expect(
screen
.getByRole("button", { name: "Bold" })
.classList.contains("custom-bold")
).toBe(true)
})
}) })
+3 -1
View File
@@ -4,13 +4,13 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@workspace/ui/components/tooltip" } from "@workspace/ui/components/tooltip"
import { useLexicalActionContext } from "./context" import { useLexicalActionContext } from "./context"
import { useLexicalMessage } from "./i18n" import { useLexicalMessage } from "./i18n"
import type { LexicalControlProps } from "./types" import type { LexicalControlProps } from "./types"
export function LexicalControl<Value = string>({ export function LexicalControl<Value = string>({
action, action,
className,
label, label,
presentation = "control", presentation = "control",
render, render,
@@ -25,6 +25,7 @@ export function LexicalControl<Value = string>({
const onClick = () => execute() const onClick = () => execute()
const renderProps = { const renderProps = {
active, active,
className,
disabled, disabled,
execute, execute,
label: resolvedLabel, label: resolvedLabel,
@@ -54,6 +55,7 @@ export function LexicalControl<Value = string>({
<Button <Button
type="button" type="button"
size="icon-sm" size="icon-sm"
className={className}
variant={active ? "secondary" : "ghost"} variant={active ? "secondary" : "ghost"}
disabled={disabled} disabled={disabled}
aria-label={resolvedLabel} aria-label={resolvedLabel}
+2
View File
@@ -67,6 +67,7 @@ export interface LexicalActionDefinition<Value = string> {
export interface LexicalControlRenderProps<Value = string> { export interface LexicalControlRenderProps<Value = string> {
active: boolean active: boolean
className?: string
disabled: boolean disabled: boolean
execute: (value?: Value) => void execute: (value?: Value) => void
label: string label: string
@@ -84,6 +85,7 @@ export interface LexicalActionDefaultControlProps<
export interface LexicalControlProps<Value = string> { export interface LexicalControlProps<Value = string> {
action: LexicalActionDefinition<Value> action: LexicalActionDefinition<Value>
className?: string
label?: LexicalMessage label?: LexicalMessage
presentation?: LexicalActionDefaultControlProps["presentation"] presentation?: LexicalActionDefaultControlProps["presentation"]
render?: (props: LexicalControlRenderProps<Value>) => ReactNode render?: (props: LexicalControlRenderProps<Value>) => ReactNode