Skip to content

Commit 944a889

Browse files
authored
fix(richtext-lexical): richtext fields don't respect RTL direction for Arabic and other RTL locales (#15964)
# Overview Fixes RTL text direction not working in Lexical richtext fields when using Arabic (or other RTL) locales. Text/Textarea fields worked fine — Lexical just wasn't wired up to detect the locale direction. ## Key Changes `Field.tsx` now calls `useLocale()` + `isFieldRTL()` and passes `rtl` down through `LexicalProvider` → `LexicalEditor`, setting `dir="rtl"` on the `editor-container` div when the locale is RTL. Lexical always sets `dir="auto"` on paragraph nodes, which defaults to LTR for empty content regardless of the parent's `dir` attribute. A CSS rule in `LexicalEditor.scss` fixes this by forcing `direction: rtl` on `[dir="auto"]` elements inside an RTL container. `fieldRTL` and `fieldLocalized` in `isFieldRTL()` were typed as `boolean` but callers already pass `boolean | undefined`. `packages/ui` has `strict: false` so this was hidden there; `packages/richtext-lexical` has `strict: true` and surfaced it. Made both optional and exported `isFieldRTL` from `@payloadcms/ui` client exports.
1 parent 4e1c04e commit 944a889

File tree

9 files changed

+95
-10
lines changed

9 files changed

+95
-10
lines changed

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
FieldDescription,
77
FieldError,
88
FieldLabel,
9+
isFieldRTL,
910
RenderCustomComponent,
11+
useConfig,
1012
useEditDepth,
1113
useEffectEvent,
1214
useField,
15+
useLocale,
1316
} from '@payloadcms/ui'
1417
import { mergeFieldStyles } from '@payloadcms/ui/shared'
1518
import { dequal } from 'dequal/lite'
@@ -51,6 +54,17 @@ const RichTextComponent: React.FC<
5154

5255
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
5356

57+
const locale = useLocale()
58+
const {
59+
config: { localization: localizationConfig },
60+
} = useConfig()
61+
62+
const rtl = isFieldRTL({
63+
fieldLocalized: localized,
64+
locale,
65+
localizationConfig: localizationConfig || undefined,
66+
})
67+
5468
const editDepth = useEditDepth()
5569

5670
const memoizedValidate = useCallback<Validate>(
@@ -193,6 +207,7 @@ const RichTextComponent: React.FC<
193207
key={JSON.stringify({ path, rerenderProviderKey })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
194208
onChange={handleChange}
195209
readOnly={disabled}
210+
rtl={rtl}
196211
value={value}
197212
/>
198213
</BulkUploadProvider>

packages/richtext-lexical/src/lexical/LexicalEditor.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
font-family: var(--font-serif);
1313
font-size: base(0.8);
1414
letter-spacing: 0.02em;
15+
16+
&[dir='rtl'] [dir='auto'] {
17+
direction: rtl;
18+
}
1519
}
1620

1721
&--show-gutter {

packages/richtext-lexical/src/lexical/LexicalEditor.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export const LexicalEditor: React.FC<
3030
{
3131
editorContainerRef: React.RefObject<HTMLDivElement | null>
3232
isSmallWidthViewport: boolean
33-
} & Pick<LexicalProviderProps, 'editorConfig' | 'onChange'>
33+
} & Pick<LexicalProviderProps, 'editorConfig' | 'onChange' | 'rtl'>
3434
> = (props) => {
35-
const { editorConfig, editorContainerRef, isSmallWidthViewport, onChange } = props
35+
const { editorConfig, editorContainerRef, isSmallWidthViewport, onChange, rtl } = props
3636
const editorConfigContext = useEditorConfigContext()
3737
const [editor] = useLexicalComposerContext()
3838
const isEditable = useLexicalEditable()
@@ -93,7 +93,7 @@ export const LexicalEditor: React.FC<
9393
return <EditorPlugin clientProps={plugin.clientProps} key={plugin.key} plugin={plugin} />
9494
}
9595
})}
96-
<div className="editor-container" ref={editorContainerRef}>
96+
<div className="editor-container" dir={rtl ? 'rtl' : undefined} ref={editorContainerRef}>
9797
{editorConfig.features.plugins?.map((plugin) => {
9898
if (plugin.position === 'top') {
9999
return (

packages/richtext-lexical/src/lexical/LexicalProvider.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type LexicalProviderProps = {
2424
isSmallWidthViewport: boolean
2525
onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void
2626
readOnly: boolean
27+
rtl?: boolean
2728
value: SerializedEditorState
2829
}
2930

@@ -50,8 +51,16 @@ const NestProviders = ({
5051
}
5152

5253
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
53-
const { composerKey, editorConfig, fieldProps, isSmallWidthViewport, onChange, readOnly, value } =
54-
props
54+
const {
55+
composerKey,
56+
editorConfig,
57+
fieldProps,
58+
isSmallWidthViewport,
59+
onChange,
60+
readOnly,
61+
rtl,
62+
value,
63+
} = props
5564

5665
const parentContext = useEditorConfigContext()
5766

@@ -117,6 +126,7 @@ export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
117126
editorContainerRef={editorContainerRef}
118127
isSmallWidthViewport={isSmallWidthViewport}
119128
onChange={onChange}
129+
rtl={rtl}
120130
/>
121131
</NestProviders>
122132
</EditorConfigProvider>

packages/ui/src/exports/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export { UIField } from '../../fields/UI/index.js'
233233
export { UploadField, UploadInput } from '../../fields/Upload/index.js'
234234
export type { UploadInputProps } from '../../fields/Upload/index.js'
235235

236-
export { fieldBaseClass } from '../../fields/shared/index.js'
236+
export { fieldBaseClass, isFieldRTL } from '../../fields/shared/index.js'
237237

238238
// forms
239239

packages/ui/src/fields/shared/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export function isFieldRTL({
1414
locale,
1515
localizationConfig,
1616
}: {
17-
fieldLocalized: boolean
18-
fieldRTL: boolean
17+
fieldLocalized?: boolean
18+
fieldRTL?: boolean
1919
locale: Locale
2020
localizationConfig?: SanitizedLocalizationConfig
2121
}) {

test/localization/collections/RichText/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CollectionConfig } from 'payload/types'
22

3-
import { lexicalEditor, TreeViewFeature } from '@payloadcms/richtext-lexical'
3+
import { FixedToolbarFeature, lexicalEditor, TreeViewFeature } from '@payloadcms/richtext-lexical'
44
import { slateEditor } from '@payloadcms/richtext-slate'
55

66
export const richTextSlug = 'richText'
@@ -24,7 +24,11 @@ export const RichTextCollection: CollectionConfig = {
2424
type: 'richText',
2525
localized: true,
2626
editor: lexicalEditor({
27-
features: ({ defaultFeatures }) => [...defaultFeatures, TreeViewFeature()],
27+
features: ({ defaultFeatures }) => [
28+
...defaultFeatures,
29+
FixedToolbarFeature(),
30+
TreeViewFeature(),
31+
],
2832
}),
2933
},
3034
],

test/localization/e2e.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,45 @@ describe('Localization', () => {
933933
})
934934
})
935935

936+
describe('RTL Lexical richtext', () => {
937+
test('should render the Lexical editor with RTL direction when Arabic locale is active', async () => {
938+
await page.goto(richTextURL.create)
939+
await changeLocale(page, 'ar')
940+
941+
const editorContainer = page.locator('.rich-text-lexical .editor-container')
942+
await expect(editorContainer).toBeVisible()
943+
944+
// editor-container should have dir="rtl" from locale detection
945+
await expect(editorContainer).toHaveAttribute('dir', 'rtl')
946+
947+
// The paragraph element should have direction: rtl (from CSS rule targeting [dir="auto"] inside RTL container)
948+
const paragraph = page.locator('.rich-text-lexical .ContentEditable__root p').first()
949+
await expect(paragraph).toHaveCSS('direction', 'rtl')
950+
})
951+
952+
test('should not render the Lexical editor with RTL direction when English locale is active', async () => {
953+
await page.goto(richTextURL.create)
954+
955+
const editorContainer = page.locator('.rich-text-lexical .editor-container')
956+
await expect(editorContainer).toBeVisible()
957+
958+
await expect(editorContainer).not.toHaveAttribute('dir', 'rtl')
959+
960+
const paragraph = page.locator('.rich-text-lexical .ContentEditable__root p').first()
961+
await expect(paragraph).not.toHaveCSS('direction', 'rtl')
962+
})
963+
964+
test('should have RTL direction in the Lexical editor before typing', async () => {
965+
await page.goto(richTextURL.create)
966+
await changeLocale(page, 'ar')
967+
968+
// Even before typing, the empty paragraph should be RTL due to our CSS fix.
969+
// Without the fix, dir="auto" on an empty paragraph defaults to LTR.
970+
const paragraph = page.locator('.rich-text-lexical .ContentEditable__root p').first()
971+
await expect(paragraph).toHaveCSS('direction', 'rtl')
972+
})
973+
})
974+
936975
describe('A11y', () => {
937976
test.fixme('Locale picker should have no accessibility violations', async ({}, testInfo) => {
938977
await page.goto(url.list)

test/localization/payload-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export interface Config {
145145
'global-drafts': GlobalDraftsSelect<false> | GlobalDraftsSelect<true>;
146146
};
147147
locale: 'xx' | 'en' | 'es' | 'pt' | 'ar' | 'hu';
148+
widgets: {
149+
collections: CollectionsWidget;
150+
};
148151
user: User;
149152
jobs: {
150153
tasks: unknown;
@@ -1816,6 +1819,16 @@ export interface GlobalDraftsSelect<T extends boolean = true> {
18161819
createdAt?: T;
18171820
globalType?: T;
18181821
}
1822+
/**
1823+
* This interface was referenced by `Config`'s JSON-Schema
1824+
* via the `definition` "collections_widget".
1825+
*/
1826+
export interface CollectionsWidget {
1827+
data?: {
1828+
[k: string]: unknown;
1829+
};
1830+
width: 'full';
1831+
}
18191832
/**
18201833
* This interface was referenced by `Config`'s JSON-Schema
18211834
* via the `definition` "auth".

0 commit comments

Comments
 (0)