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:
@@ -58,6 +58,7 @@ export type LexicalActionPlacement =
|
||||
|
||||
export interface LexicalActionProps<Value = string> {
|
||||
children?: (props: LexicalControlRenderProps<Value>) => 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 {
|
||||
<>
|
||||
<Undo />
|
||||
<Redo />
|
||||
<Bold in={formattingAreas} />
|
||||
<Italic in={formattingAreas} />
|
||||
<Underline in={formattingAreas} />
|
||||
<Link in={formattingAreas} />
|
||||
<ClearFormatting in={formattingAreas} />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.paragraphStyle}
|
||||
@@ -242,11 +249,6 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
|
||||
<BulletList />
|
||||
<OrderedList />
|
||||
</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} />
|
||||
<Link in={formattingAreas} />
|
||||
<TextColor in={formattingAreas} />
|
||||
<Strikethrough in="bubble" />
|
||||
<Subscript in="bubble" />
|
||||
<Superscript in="bubble" />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.moreFormatting}
|
||||
@@ -289,10 +294,6 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
|
||||
<Subscript />
|
||||
<Superscript />
|
||||
</ActionGroup>
|
||||
<Strikethrough in="bubble" />
|
||||
<Subscript in="bubble" />
|
||||
<Superscript in="bubble" />
|
||||
<ClearFormatting in={formattingAreas} />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.insert}
|
||||
@@ -322,6 +323,7 @@ function getPresetActions(preset: LexicalActionsPreset): ReactNode {
|
||||
<Indent />
|
||||
</ActionGroup>
|
||||
</ActionGroup>
|
||||
<ClearFormatting in={formattingAreas} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<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", () => {
|
||||
render(
|
||||
<LexicalRoot value="" onChange={() => undefined}>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function LexicalActionTree({
|
||||
<LexicalControl
|
||||
key={item.key}
|
||||
action={item.action}
|
||||
className={item.className}
|
||||
render={item.render}
|
||||
/>
|
||||
) : 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 (
|
||||
<LexicalControl
|
||||
action={action}
|
||||
className={actionClassName}
|
||||
presentation="menu-item"
|
||||
render={render}
|
||||
/>
|
||||
@@ -118,10 +120,11 @@ function ActionMenuItem({ item }: { item: CompiledLexicalAction }) {
|
||||
return (
|
||||
<LexicalControl
|
||||
action={action}
|
||||
render={({ active, disabled, label, onClick }) => (
|
||||
className={actionClassName}
|
||||
render={({ active, className, disabled, label, onClick }) => (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
className={cn(active && "bg-muted")}
|
||||
className={cn(active && "bg-muted", className)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -19,8 +19,8 @@ describe("Lexical action rendering", () => {
|
||||
<LexicalRoot value="" onChange={() => undefined}>
|
||||
<LexicalActions>
|
||||
<Bold>
|
||||
{({ active, label, onClick }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{({ active, className, label, onClick }) => (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{label}:{active ? "on" : "off"}
|
||||
</button>
|
||||
)}
|
||||
@@ -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(
|
||||
<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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Value = string>({
|
||||
action,
|
||||
className,
|
||||
label,
|
||||
presentation = "control",
|
||||
render,
|
||||
@@ -25,6 +25,7 @@ export function LexicalControl<Value = string>({
|
||||
const onClick = () => execute()
|
||||
const renderProps = {
|
||||
active,
|
||||
className,
|
||||
disabled,
|
||||
execute,
|
||||
label: resolvedLabel,
|
||||
@@ -54,6 +55,7 @@ export function LexicalControl<Value = string>({
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
className={className}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
disabled={disabled}
|
||||
aria-label={resolvedLabel}
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface LexicalActionDefinition<Value = string> {
|
||||
|
||||
export interface LexicalControlRenderProps<Value = string> {
|
||||
active: boolean
|
||||
className?: string
|
||||
disabled: boolean
|
||||
execute: (value?: Value) => void
|
||||
label: string
|
||||
@@ -84,6 +85,7 @@ export interface LexicalActionDefaultControlProps<
|
||||
|
||||
export interface LexicalControlProps<Value = string> {
|
||||
action: LexicalActionDefinition<Value>
|
||||
className?: string
|
||||
label?: LexicalMessage
|
||||
presentation?: LexicalActionDefaultControlProps["presentation"]
|
||||
render?: (props: LexicalControlRenderProps<Value>) => ReactNode
|
||||
|
||||
Reference in New Issue
Block a user