Skip to content

fix: render the editor in the locale's text direction - #566

Merged
dcalhoun merged 21 commits into
trunkfrom
feat/rtl-text-direction-support
Jul 30, 2026
Merged

fix: render the editor in the locale's text direction#566
dcalhoun merged 21 commits into
trunkfrom
feat/rtl-text-direction-support

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Jul 27, 2026

Copy link
Copy Markdown
Member

What?

Renders the editor in the reading direction of the loaded translations, instead of always left-to-right, and declares the editor's language to assistive technology. Both demo apps gain a way to switch languages so it can be tested.

Why?

Fix CMM-1099. Fix CMM-2188.

The editor loaded the correct translations for all 49 shipped locales but always rendered them left-to-right. src/index.html hardcodes lang="en" with no dir, and nothing set either at runtime.

For the four right-to-left locales we ship translations for — Arabic, Persian, Hebrew, Urdu — that meant:

  • Mirrored layout was absent. CSS logical properties resolved as left-to-right, so toolbars, sidebars, and inspector panels laid out backwards.
  • Bidirectional text ran wrong. Mixed-direction content resolved under a left-to-right base direction, so trailing punctuation landed on the wrong end — in the user's post content, not just the chrome.
  • Controls pointed the wrong way. Components across components, block-editor, block-library, and editor call isRTL() at runtime to choose icons, accessibility labels, keyboard navigation, and drop-zone geometry. It returned false, so block movers pointed the wrong way and announced the opposite of what they did.
  • Assistive technology mispronounced the UI. lang="en" tells screen readers to apply English phonetics to every language — a WCAG 3.1.1 failure that affects all 49 locales, not only the right-to-left ones. VoiceOver honors the corrected lang; TalkBack's support is partial, see below.

In WordPress, core establishes all of this: it renders <html lang dir> and <body class="rtl">, populates the text direction string behind isRTL(), and swaps each enqueued stylesheet for its -rtl variant. GutenbergKit loads a static index.html, so nothing performed that role.

Neither demo app called setLocale, so the editor always ran in English regardless of the device or scheme language — which is why this went unnoticed.

How?

Text directionconfigureLocale() derives direction from the resolved locale and applies it two ways: documentElement.lang / dir and body.rtl for CSS, and a setLocaleData() entry for the text direction key that backs isRTL(). The second half is what fixes the icons, labels, and keyboard navigation that CSS cannot reach. The string is injected rather than read from the bundle because the wp-plugins/gutenberg GlotPress project does not carry it — it belongs to core.

Stylesheets — both the left-to-right and right-to-left variants are imported, and one is injected at runtime. The -rtl bundles are full rewrites rather than overrides: across the five editor stylesheets roughly 690 selectors appear in both files with conflicting declarations and almost no [dir=rtl] scoping, so loading both would let source order decide the direction every user gets. Both variants ship in the bundle rather than being fetched on demand — the assets are already bundled into the host app, so the added weight costs no network time, and selecting synchronously avoids introducing async work before first paint.

The block canvas iframe needs no change: it inherits dir from the parent document.

Accessibility language — the web content declares its own language via documentElement.lang, but the native UI presented alongside it does not. EditorViewController sets accessibilityLanguage from the configured locale in viewDidLoad, so VoiceOver selects a matching speech voice. The value is inherited, including across modal presentations, so the block inserter and the sheets it presents are covered by that single assignment.

Toolbar scroll indicators — the gradients marking overflow read scrollLeft directly, which is 0 at the right edge and grows negative moving left in a right-to-left container. The left gradient was therefore unreachable and the right one showed at rest. The offset is normalized to a distance from the start and mapped onto the physical edges the gradients are anchored to, which do not flip with the writing direction. Fixes #565.

Demo apps — iOS reads the scheme's App Language; Android declares a localeConfig so it appears in the system's per-app language picker, and links to it from the configuration screen. Both surface the resolved locale, since a language with no bundled translations falls back to English, which is otherwise indistinguishable from the selection being ignored.

DemoAppLocale.swift duplicates the resolution chain that #492 adds to the library as LocaleResolver, matching Android's already merged in #493. It is scoped to one file so reviving that PR removes it wholesale, leaving a one-line call change. Android needs no such helper — setLocale(Locale) already resolves.

Testing Instructions

iOS

  1. Edit the Gutenberg scheme → Run → Options → App Language, and select Arabic (or Hebrew).
  2. Run the demo app and open a site's Editor Configuration.
  3. Confirm the Editor Locale row reads ar.
  4. Start the editor and confirm the UI is mirrored, the toolbar reads right-to-left, and translations are in Arabic.
  5. Repeat with Spanish to confirm a left-to-right locale still translates without mirroring.
  6. Set App Language back to System Language and confirm the editor returns to English, left-to-right.

Xcode's Right-to-Left Pseudolanguage options are deliberately not supported — they are layout overrides rather than languages, so the app around the editor flips while the editor does not. Use a real right-to-left language.

Android

  1. Run the demo app and open a site's Editor Configuration.
  2. Tap Change on the Editor Locale row to open the system language picker (Android 13+).
  3. Select العربية, then return to the app.
  4. Confirm the Editor Locale row updates to ar without backing out of the screen.
  5. Start the editor and confirm the UI is mirrored and translations are in Arabic.
  6. Repeat with Español to confirm a left-to-right locale still translates without mirroring.
  7. Set the language back to System default and confirm the editor returns to English.

The native block inserter stays English on Android in every locale — its strings are Android resources with no localized variants. Unchanged by this PR, and noted under Follow-ups.

Accessibility Testing Instructions

Speech voice — iOS

  1. Install a voice for the test language under Settings → Accessibility → VoiceOver → Speech, and set the app to that language.
  2. Navigate the editor. Confirm strings are spoken by that language's voice rather than the default one.
  3. Open the native block inserter and confirm the same, including the patterns sheet.

Applies to every locale, not only the right-to-left ones.

Speech voice — Android: partially out of reach

Nothing to test. TalkBack does not apply lang when it builds accessibility nodes for interactive controls.

Screenshots or screencast

Android iOS
android ios

Follow-ups

Android's native strings are not localized. The library ships only values/, and unlike iOS there is no host-override hook, so the block inserter is English in every locale. Deferred — it needs either localized resources or an EditorLocalization equivalent, and is independent of text direction.

Preloaded REST data is not localized — GutenbergKit sends no Accept-Language or _locale, so post-type labels and theme strings stay English in every locale. Out of scope here; the response cache is keyed only by method and URL, which is correct today precisely because nothing locale-specific is ever fetched. Worth revisiting if that changes.

@github-actions github-actions Bot added the [Type] Enhancement A suggestion for improvement. label Jul 27, 2026
@dcalhoun dcalhoun added [Type] Bug An existing feature does not function as intended iOS Android Webviews labels Jul 27, 2026
@wpmobilebot

wpmobilebot commented Jul 27, 2026

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/566")

Built from 9bc49b5

Comment thread src/index.scss
Comment on lines -70 to -75
.block-inspector-siderbar {
background: #f6f6fbff;
border-left: 0.5px solid #c8c7cc;
width: 320px;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Discovered as dead code and removed.

dcalhoun and others added 10 commits July 27, 2026 19:38
The editor renders every locale left-to-right. `src/index.html` hardcodes
`lang="en"` with no `dir`, and nothing sets them at runtime, so the four
right-to-left bundles we ship (`ar`, `fa`, `he`, `ur`) get English
phonetics from screen readers and an LTR layout.

In WordPress, core establishes this: it renders `<html lang dir>`,
`<body class="rtl">`, and populates the `text direction` string backing
`isRTL()`. GutenbergKit loads a static `index.html`, so nothing plays
that role.

Derive direction from the resolved locale — a fixed property of the
language, already resolved to a shipped tag before it reaches JS — and
apply it to both the document and `@wordpress/i18n`. The `setLocaleData`
entry matters independently of CSS: components across `components`,
`block-editor`, `block-library`, and `editor` call `isRTL()` at runtime
to pick icons, accessibility labels, keyboard navigation, and drop-zone
geometry.

The string is injected rather than read from the bundle because the
`wp-plugins/gutenberg` GlotPress project doesn't carry it — it belongs
to core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor imported only the left-to-right Gutenberg stylesheets, so
right-to-left locales rendered translated strings in a mirrored layout:
logical properties resolved backwards, and rules Gutenberg guards with
`body.rtl` or `html[dir=rtl]` never matched.

Import both variants and inject one at runtime. The `-rtl` bundles are
full rewrites rather than overrides — across the five editor stylesheets
roughly 690 selectors appear in both files with conflicting declarations
and almost no `[dir=rtl]` scoping — so loading both would let source
order decide the direction every user gets. WordPress swaps the enqueued
file server-side; the equivalent choice happens here because the editor
loads a single static `index.html`.

Both variants ship in the bundle rather than being fetched on demand.
The assets are already bundled into the host app, so the added weight
costs no network time, and selecting synchronously avoids introducing
async work before first paint.

`default-editor-styles.css` is left alone: it and its `-rtl` sibling are
byte-identical, containing nothing directional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The demo app never called `setLocale`, so the editor always ran in
English regardless of the scheme's *App Language* setting. Testing a
translation meant editing code.

Resolve the launch language against the locales the editor ships and
pass the result through `applyDemoAppDefaults`, the single funnel every
demo configuration already flows through.

Read `Locale.preferredLanguages` rather than
`Bundle.main.preferredLocalizations`. The latter filters against the
localizations the app bundle itself ships, and the demo app ships only
English, so every selection would collapse to `en`.

Surface the resolved locale in the configuration details, noting the
requested language when it differs. A language with no shipped bundle
renders in English, which is otherwise indistinguishable from the
selection being ignored.

Xcode's right-to-left pseudolanguages are explicitly not supported. They
are layout overrides rather than languages — Xcode passes
`-AppleTextDirection YES -NSForceRightToLeftWritingDirection YES` with no
`-AppleLanguages` — so UIKit mirrors the surrounding app while the editor,
which renders in a web view and keys off the locale, does not. Testing
right-to-left rendering means selecting a real language such as Arabic or
Hebrew.

`DemoAppLocale` duplicates the resolution chain that #492 adds to the
library as `LocaleResolver`, matching Android's already merged in #493.
It is scoped to one file so reviving #492 removes it wholesale, leaving
a one-line call change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Absolutely targeting the right border resulted in unexpected styling for
RTL language layout.
The demo app never called `setLocale`, so the editor always ran in
English regardless of the device or per-app language. Testing a
translation — or right-to-left rendering — meant editing code.

Declare a `localeConfig` so the app appears in the system's per-app
language picker, read the selection, and pass it to both configuration
paths. No resolution logic is needed on this side:
`EditorConfiguration.Builder.setLocale(Locale)` already resolves against
the bundled translations via the library's `LocaleResolver`.

The selection is read from the platform's `LocaleManager` rather than
`AppCompatDelegate.getApplicationLocales()`. That helper resolves the
application locale by walking appcompat's registry of live activity
delegates, and every activity in this app extends `ComponentActivity`
rather than `AppCompatActivity`, so the registry is always empty and the
helper reports no selection regardless of what the system holds.

The configuration is rebuilt when the locale changes. Android recreates
the activity on a locale change, but the view model survives it, so
loading the configuration from `LaunchedEffect(Unit)` would keep serving
the one built with the previous locale.

The offered languages are a curated subset rather than all ~49 shipped
locales, each covering a distinct rendering path: `ar` for right-to-left
with cursive shaping, `he` for right-to-left without it, `ja` for CJK
glyph selection and line breaking, `pt-BR` for the resolver's regional
step, and `en`/`es`/`fr` as Latin baselines.

Surface the resolved locale in the configuration details, linking to the
system picker. A language with no bundled translations resolves to `en`,
which is otherwise indistinguishable from the selection being ignored.
The link is omitted below Android 13, which has no per-app language
screen to open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Avoid absolute direction styles that break RTL language layouts.
The relevant UI element no longer exists.
Tapping "Change" and selecting a language left the Editor Configuration
screen showing the previous locale. Backing out and re-entering was
required to see the new one.

The locale was read once during composition. Changing the per-app
language does not necessarily recreate the activity — the system picker
belongs to another task, so this activity is merely stopped and resumed —
and nothing prompted the composition to re-read the value on return.

Re-read the locale on `ON_RESUME` and drive the configuration reload from
that state, so both paths are covered: activity recreation where it
happens, and a plain resume where it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remove comments deemed unnecessary.
The type reads platform state and resolves it to a locale — the same
kind of thing as the other members of `Services`. It was placed under
`Views` only because that group is file-system synchronized, so files
added there compile without editing `project.pbxproj`.

Drop the note explaining that placement, which no longer applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun force-pushed the feat/rtl-text-direction-support branch from 4ec3902 to b9632b5 Compare July 27, 2026 23:38
dcalhoun and others added 5 commits July 28, 2026 10:17
VoiceOver read the native block inserter with the host app's default
speech voice, so an Italian block list was announced by an English
engine. The web content sets `documentElement.lang`, which VoiceOver
honors, but the native surfaces are a separate accessibility tree that
declared nothing and fell back to the app's language.

Set `accessibilityLanguage` on the inserter's hosting view, and on the
camera and patterns sheets. `accessibilityLanguage` is inherited down a
view hierarchy, but a sheet is presented as a sibling rather than a
descendant, so each presentation boundary has to declare it. The locale
travels through the SwiftUI environment, which does cross sheets.

SwiftUI has no equivalent modifier — `accessibilityLanguage` exists only
on `UIView` and `UIAccessibilityElement` — so `editorAccessibilityLanguage()`
bridges to UIKit through a representable.

Strings the host supplies rather than the editor are covered too. A host
localizes itself to the same locale it passes to `setLocale`, so its
strings are in that language; where they are not, the host is shipping
untranslated strings and the library should not model around it.

The system photo picker renders out of process and owns its own
accessibility tree, so it cannot be annotated from here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guage

With English first and French second in iOS's preferred languages, the
editor loaded French. With English alone it loaded English.

English is the editor's source language, so no `en` bundle ships and the
lookup cannot match it. The resolver treated that miss as "this language
is unavailable, try the next one" and moved on to French — but the user's
first choice was English, and English is exactly what the editor renders
without a bundle. It only appeared correct with a single preferred
language because the loop then ran out and hit the same default.

Stop the search on any English tag. Regional variants that do ship —
`en-gb`, `en-au` — still match before that check, and genuinely
unshipped languages still fall through.

Describe the outcome accurately too: "fr — resolved from en-US" implied
`en-US` legitimately maps to French, and "en — no bundle, using default
from en-US" would still misdescribe asking for English and getting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`swift test` builds the package for the host platform, where UIKit is
unavailable, so the `UIViewRepresentable` bridge failed to compile and
took the library test suite with it.

Wrap the file in `#if canImport(UIKit)`, matching the sibling views. Both
call sites already sit inside the same guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stylesheets moved from a side-effect import to a runtime injection so the
direction variant can be chosen at load. Vite hoisted the import ahead of
`index.scss`, while appending places it after the stylesheet link.

These stylesheets are the base layer GutenbergKit's own styles build on, and
several selectors tie on specificity across the two, which the cascade then
resolves by source order. Insert before the first stylesheet to preserve the
order the import produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In a right-to-left container `scrollLeft` is `0` at the right edge and grows
negative moving left, so comparing it directly against zero left the left
gradient permanently hidden and the right gradient shown even at the start
edge.

Normalize the offset to a distance from the start, then map it onto the
physical edges the gradients are anchored to, which do not flip with the
writing direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun force-pushed the feat/rtl-text-direction-support branch from 21c76c4 to a970b6b Compare July 28, 2026 14:18
dcalhoun and others added 5 commits July 28, 2026 11:00
`configureLocale` resolved the text direction to set on the document, then
`setUpEditorEnvironment` resolved it a second time from the raw
`getGBKit().locale` to choose the stylesheets. The two agree today only
because `isRTLLocale(undefined)` happens to match the `en` default, so a
different default or a normalized locale value would leave the chrome and
canvas stylesheets disagreeing on direction.

Return the resolved direction from `configureLocale` and pass it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`updateScrollState` is the scroll listener, so reading the container's
computed `direction` forced a style resolution on every frame of a
touch-dragged toolbar. The direction is set once at startup and fixed for
the editor's lifetime.

Read `documentElement.dir` instead, matching how the visual editor already
selects its stylesheets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…archy

`updateUIView` runs before the representable's view is necessarily attached
to a superview, and with no superview the walk up the responder chain finds
no hosting controller. The language never changes afterward, so SwiftUI had
no reason to call `updateUIView` again and the miss was permanent —
VoiceOver would read localized strings with the device voice.

Re-apply on move to a window so the assignment retries once the hierarchy
exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ACTION_APP_LOCALE_SETTINGS` is optional even on API 33+ and some devices
ship no handler for it, so gating the row on the SDK level alone meant
tapping "Editor Locale" threw an uncaught `ActivityNotFoundException`.

Resolve the intent up front and fall back to the plain read-only row.
Also drop a local left unused by the resume-aware locale read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hasPrefix("en")` matches any tag starting with those two letters, such as
`enm` for Middle English, rather than the English language subtag.

Reuse `DemoAppLocale.isEnglish`, which already parses the subtag for the
same purpose during resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread ios/Sources/GutenbergKit/Sources/Views/EditorAccessibilityLanguage.swift Outdated
@dcalhoun
dcalhoun marked this pull request as ready for review July 28, 2026 15:45
@dcalhoun dcalhoun removed [Type] Enhancement A suggestion for improvement. Android Webviews iOS labels Jul 28, 2026
private static func applyDemoAppDefaults(to configuration: EditorConfiguration) -> EditorConfiguration {
configuration.toBuilder()
.setNativeInserterEnabled(true)
.setLocale(DemoAppLocale.current)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it a requirement for the app to call this function to support text direction in GBK? If we need to call this function, I presume we'd need to duplicate the resolve in the app, too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, that is by design. We do currently invoke this function within WordPress-iOS.

Regarding resolve, that is pointedly addressed for Android by #493. The in-progress #492 does so iOS. So, it's an existing problem not addressed by this PR.

The DemoAppLocale is intended as a stopgap for resolving locales on iOS to unblock addressing the issues stemming from the currently absent lang and dir support. When merging #492, we should remove DemoAppLocale altogether. I opted to keep the two matters (locale resolutions and lang/dir) separate to avoid entangling PRs.

/// controller. Because the language does not change afterward, SwiftUI has no
/// reason to call `updateUIView` again, so a miss there would be permanent.
/// Re-applying on move to a window retries once the hierarchy exists.
private final class AccessibilityLanguageView: UIView {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any particular reason creating this view, instead of updating accessibilityLanguage on the EditorViewController?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I appreciate this feedback and guidance. I re-tested with your suggested approach in both GBK Demo and WP-iOS and it does work as it should.

At some point, my earlier testing encountered unexpected VoiceOver Speech Voices that mismatched the selected locale when navigating native modals (e.g., native block inserter sheet, patterns sheet, camera sheet). With Claude, I came to believe the sibling nature of the modal presentations were the issue and required this more complex approach. From testing your proposed change, it that appears misguided.

Addressed this in 9bc49b5.

`accessibilityLanguage` was applied at every presentation boundary: an
imperative assignment on the block inserter's hosting controller, and an
`editorAccessibilityLanguage()` modifier on the sheets presented from it.
The modifier existed because modal presentations are siblings of their
presenter rather than descendants, which was assumed to break inheritance.

Testing showed it does not. With the app running in English and the editor
locale pinned to French, a single assignment on `EditorViewController.view`
gives a matching speech voice on the editor chrome, the native inserter, the
patterns sheet, and the camera sheet — identical to annotating each boundary,
and confirmed against WordPress-iOS.

Removes `EditorAccessibilityLanguage.swift` along with the `UIViewRepresentable`
shim, its responder-chain walk, and the `didMoveToWindow` retry that existed
only to work around the shim's timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcalhoun
dcalhoun merged commit 74a446e into trunk Jul 30, 2026
24 checks passed
@dcalhoun
dcalhoun deleted the feat/rtl-text-direction-support branch July 30, 2026 14:17
@dcalhoun dcalhoun changed the title feat: render the editor in the locale's text direction fix: render the editor in the locale's text direction Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Toolbar scroll-indicator gradients render on the wrong side in RTL layouts

3 participants