diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d5a954927..07a86baac 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GutenbergKit"
diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt
new file mode 100644
index 000000000..cd08febb0
--- /dev/null
+++ b/android/app/src/main/java/com/example/gutenbergkit/DemoAppLocale.kt
@@ -0,0 +1,50 @@
+package com.example.gutenbergkit
+
+import android.app.LocaleManager
+import android.content.Context
+import android.os.Build
+import java.util.Locale
+
+/**
+ * Reads the language the demo app is running in, so the editor can be told
+ * which translations to load.
+ *
+ * The language is chosen through the system's per-app language picker
+ * (Settings > Apps > GutenbergKit > Language), which offers the locales
+ * declared in `res/xml/locales_config.xml`. Forwarding it to
+ * `EditorConfiguration` lets the editor's localization — including
+ * right-to-left rendering — be exercised without code changes.
+ *
+ * Unlike the iOS demo app, no resolution logic lives here:
+ * `EditorConfiguration.Builder.setLocale(Locale)` already resolves against the
+ * bundled translations via the library's `LocaleResolver`.
+ */
+object DemoAppLocale {
+
+ /**
+ * The locale to hand the editor.
+ *
+ * Reads the platform's [LocaleManager] directly rather than going through
+ * `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.
+ *
+ * Falls back to the device language when no per-app language is set, or on
+ * Android versions predating per-app languages (API < 33).
+ */
+ fun current(context: Context): Locale {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ return Locale.getDefault()
+ }
+
+ val locales = context.getSystemService(LocaleManager::class.java)
+ ?.applicationLocales
+
+ if (locales == null || locales.isEmpty) {
+ return Locale.getDefault()
+ }
+ return locales[0]
+ }
+}
diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
index fa5197eae..9a7346a80 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
@@ -2,11 +2,15 @@ package com.example.gutenbergkit
import android.content.Context
import android.content.Intent
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.provider.Settings
import org.json.JSONObject
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -43,17 +47,26 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.compose.LocalLifecycleOwner
import com.example.gutenbergkit.ui.theme.AppTheme
+import java.util.Locale
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorDependencies
import org.wordpress.gutenberg.model.EditorDependenciesSerializer
@@ -203,7 +216,11 @@ fun SitePreparationScreen(
) {
val uiState by viewModel.uiState.collectAsState()
- LaunchedEffect(Unit) {
+ // Re-read on resume so returning from the system language picker is
+ // noticed.
+ val locale = rememberLocaleOnResume()
+
+ LaunchedEffect(locale) {
viewModel.startLoading()
}
@@ -515,8 +532,85 @@ private fun EditorConfigurationDetailsCard(configuration: EditorConfiguration) {
KeyValueRow(key = "API Root", value = configuration.siteApiRoot)
KeyValueBooleanRow(key = "Supports Block Assets", value = configuration.plugins)
KeyValueBooleanRow(key = "Supports Theme Styles", value = configuration.themeStyles)
+ EditorLocaleRow(locale = configuration.locale)
+ }
+ }
+}
+
+/**
+ * The app's current locale, re-read every time the activity resumes.
+ *
+ * Returning from the system language picker does not reliably recreate this
+ * activity — the picker belongs to another task, so this one is often just
+ * stopped and resumed — and a plain read during composition would never see
+ * the new value. Observing `ON_RESUME` covers both cases.
+ */
+@Composable
+private fun rememberLocaleOnResume(): Locale {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var locale by remember { mutableStateOf(DemoAppLocale.current(context)) }
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ locale = DemoAppLocale.current(context)
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ return locale
+}
+
+/**
+ * Shows the locale the editor will use, linking to the system's per-app
+ * language picker where one exists.
+ *
+ * The value is what the library resolved the app's language to, not the
+ * language itself — a locale with no bundled translations resolves to `en`,
+ * which is otherwise indistinguishable from the selection being ignored.
+ */
+@Composable
+private fun EditorLocaleRow(locale: String?) {
+ val context = LocalContext.current
+ val resolved = locale ?: "en"
+
+ // The action is optional even on API 33+ — some devices ship no handler for
+ // it — so resolve the intent rather than inferring availability from the SDK
+ // level, which would throw on tap.
+ val settingsIntent = remember(context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ null
+ } else {
+ Intent(
+ Settings.ACTION_APP_LOCALE_SETTINGS,
+ Uri.fromParts("package", context.packageName, null)
+ ).takeIf { it.resolveActivity(context.packageManager) != null }
}
}
+
+ if (settingsIntent == null) {
+ KeyValueRow(key = "Editor Locale", value = resolved)
+ return
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { context.startActivity(settingsIntent) }
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ KeyValueRow(key = "Editor Locale", value = resolved)
+ Text(
+ text = "Change",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
}
@Composable
diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
index 360b08af0..cc43e89d5 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
@@ -224,6 +224,7 @@ class SitePreparationViewModel(
.setAuthHeader("")
.setCookies(emptyMap())
.setEnableOfflineMode(true)
+ .setLocale(DemoAppLocale.current(getApplication()))
.build()
}
@@ -278,6 +279,7 @@ class SitePreparationViewModel(
.setCookies(emptyMap())
.setEnableNetworkLogging(true)
.setEnableAssetCaching(capabilities.supportsPlugins)
+ .setLocale(DemoAppLocale.current(getApplication()))
.build()
}
diff --git a/android/app/src/main/res/xml/locales_config.xml b/android/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 000000000..cf0e0969a
--- /dev/null
+++ b/android/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
index 185d047c4..296a464c5 100644
--- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
+++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
@@ -15,6 +15,7 @@
246852562EAABB7800ED1F09 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4F59A12BEFF4980028BD96 /* WordPressAPI */; };
2468526B2EAACCA100ED1F09 /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852682EAACCA100ED1F09 /* AuthenticationManager.swift */; };
2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */; };
+ 2FCF7A593017EC80008F5560 /* DemoAppLocale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */; };
BB0000012F11000000000001 /* GutenbergKitHTTP in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000012F11000000000002 /* GutenbergKitHTTP */; };
/* End PBXBuildFile section */
@@ -36,6 +37,7 @@
0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
246852682EAACCA100ED1F09 /* AuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationManager.swift; sourceTree = ""; };
246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStorage.swift; sourceTree = ""; };
+ 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppLocale.swift; sourceTree = ""; };
AA0000012F00000000000001 /* GutenbergUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GutenbergUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -124,6 +126,7 @@
isa = PBXGroup;
children = (
246852682EAACCA100ED1F09 /* AuthenticationManager.swift */,
+ 2FCF7A503017EC80008F5560 /* DemoAppLocale.swift */,
246852692EAACCA100ED1F09 /* ConfigurationStorage.swift */,
);
path = Services;
@@ -272,6 +275,7 @@
0C4F59A62BEFF4980028BD96 /* ConfigurationItem.swift in Sources */,
0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */,
2468526B2EAACCA100ED1F09 /* AuthenticationManager.swift in Sources */,
+ 2FCF7A593017EC80008F5560 /* DemoAppLocale.swift in Sources */,
2468526C2EAACCA100ED1F09 /* ConfigurationStorage.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift
new file mode 100644
index 000000000..4d47adcaf
--- /dev/null
+++ b/ios/Demo-iOS/Sources/Services/DemoAppLocale.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+/// Resolves the language the app was launched in to a locale the editor ships
+/// translations for.
+///
+/// Xcode's *App Language* scheme option launches the app with
+/// `-AppleLanguages ()`, which surfaces in `Locale.preferredLanguages`.
+/// Forwarding that to `EditorConfiguration` lets the demo app exercise the
+/// editor's localization by changing a dropdown rather than editing code.
+///
+/// - Note: `Bundle.main.preferredLocalizations` is deliberately *not* used. It
+/// filters against the localizations the app bundle itself ships, and the
+/// demo app ships only English, so every selection would collapse to `en`.
+///
+/// - Note: Xcode's *Right-to-Left Pseudolanguage* options are not supported.
+/// They are not languages: Xcode launches the app with `-AppleTextDirection
+/// YES -NSForceRightToLeftWritingDirection YES` and no `-AppleLanguages`, so
+/// `Locale.preferredLanguages` still reports the device language and the
+/// editor loads the corresponding translations. UIKit mirrors its own layout
+/// from those flags, so the app around the editor will flip while the editor
+/// itself does not. To exercise right-to-left rendering in the editor, select
+/// a real right-to-left language such as Arabic or Hebrew.
+///
+/// - Important: This duplicates the resolution chain that
+/// [PR #492](https://github.com/wordpress-mobile/GutenbergKit/pull/492) adds
+/// to the library as `LocaleResolver`, matching the Android implementation
+/// already merged in #493. It exists only because the iOS half is frozen.
+/// When that lands, delete this type and pass `Locale.current` to
+/// `setLocale(_:)` directly — the library will do the resolving.
+enum DemoAppLocale {
+
+ /// The editor locale matching the language the app is running in.
+ static var current: String {
+ resolve(preferredLanguages: Locale.preferredLanguages)
+ }
+
+ /// Resolves the first supported locale among `preferredLanguages`.
+ ///
+ /// Falls back to English when nothing matches, mirroring the editor's own
+ /// behavior for unshipped locales.
+ static func resolve(
+ preferredLanguages: [String],
+ supportedLocales: Set = Self.supportedLocales
+ ) -> String {
+ for language in preferredLanguages {
+ if let match = resolve(language: language, supportedLocales: supportedLocales) {
+ return match
+ }
+
+ // English is the editor's source language, so no `en` bundle ships
+ // and the lookup above cannot match it. Stop rather than falling
+ // through to the next preferred language: the user asked for
+ // English, and English is what the editor renders without a bundle.
+ // Regional variants that do ship — `en-gb`, `en-au` — match above.
+ if isEnglish(language) {
+ return defaultLocale
+ }
+ }
+ return defaultLocale
+ }
+
+ /// Whether a tag's language subtag is English, regardless of region.
+ static func isEnglish(_ language: String) -> Bool {
+ let normalized = language.replacingOccurrences(of: "_", with: "-")
+ return Locale.Components(identifier: normalized)
+ .languageComponents.languageCode?.identifier.lowercased() == defaultLocale
+ }
+
+ /// Resolution chain for a single tag, mirroring the Android `LocaleResolver`:
+ /// `language-region`, then a script-implied region, then the bare language.
+ ///
+ /// Tags carrying a private-use region need no special handling: `XA`/`XB`
+ /// match no bundle, so the chain falls through to the base language.
+ private static func resolve(language: String, supportedLocales: Set) -> String? {
+ let normalized = language.replacingOccurrences(of: "_", with: "-")
+ let components = Locale.Components(identifier: normalized)
+
+ guard let code = components.languageComponents.languageCode?.identifier.lowercased(),
+ !code.isEmpty
+ else {
+ return nil
+ }
+
+ // Android's `Locale` still emits legacy ISO 639-1 codes for these
+ // languages. Aliased here too so both platforms resolve alike.
+ let language = languageAliases[code] ?? code
+
+ if let region = components.languageComponents.region?.identifier.lowercased() {
+ let tag = "\(language)-\(region)"
+ if supportedLocales.contains(tag) {
+ return tag
+ }
+ }
+
+ // For macrolanguages shipped only as regional bundles (`zh-cn`,
+ // `zh-tw`), a script subtag indicates which one is intended.
+ if let script = components.languageComponents.script?.identifier.lowercased(),
+ let implied = scriptImpliedTag(language: language, script: script),
+ supportedLocales.contains(implied) {
+ return implied
+ }
+
+ return supportedLocales.contains(language) ? language : nil
+ }
+
+ private static func scriptImpliedTag(language: String, script: String) -> String? {
+ switch (language, script) {
+ case ("zh", "hans"): return "zh-cn"
+ case ("zh", "hant"): return "zh-tw"
+ default: return nil
+ }
+ }
+
+ private static let languageAliases = [
+ "iw": "he",
+ "in": "id",
+ "no": "nb",
+ ]
+
+ static let defaultLocale = "en"
+
+ /// The locales the editor ships translations for.
+ ///
+ /// Mirrors `supported-locales.json`, which the JS build emits from
+ /// `src/translations/`. Hardcoded rather than read from the resource bundle
+ /// because this whole type is temporary scaffolding — see the type-level
+ /// note. Duplicating the list here keeps the eventual deletion to a single
+ /// file, with no library API added and then removed.
+ static let supportedLocales: Set = [
+ "ar", "bg", "bo", "ca", "cs", "cy", "da", "de", "el",
+ "en-au", "en-ca", "en-gb", "en-nz", "en-za",
+ "es", "es-ar", "es-cl", "es-cr", "fa", "fr", "gl", "he", "hr", "hu",
+ "id", "is", "it", "ja", "ka", "ko", "nb", "nl", "nl-be", "pl",
+ "pt", "pt-br", "ro", "ru", "sk", "sq", "sr", "sv", "th", "tr",
+ "uk", "ur", "vi", "zh-cn", "zh-tw",
+ ]
+}
diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
index 68d6e0b8a..3d7142677 100644
--- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
+++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
@@ -97,6 +97,7 @@ struct SitePreparationView: View {
KeyValueRow(key: "API Root", value: editorConfiguration.siteApiRoot.absoluteString)
KeyValueRow(key: "Supports Block Assets", value: editorConfiguration.shouldUsePlugins)
KeyValueRow(key: "Supports Theme Styles", value: editorConfiguration.shouldUseThemeStyles)
+ KeyValueRow(key: "Editor Locale", value: localeSummary(for: editorConfiguration))
}
}
@@ -110,6 +111,36 @@ struct SitePreparationView: View {
}
}
+ /// Describes the locale the editor will use, and the language it was
+ /// resolved from when the two differ.
+ ///
+ /// Makes the Xcode *App Language* selection self-verifying: without it, a
+ /// language with no shipped bundle silently renders in English and looks
+ /// identical to the selection being ignored entirely.
+ private func localeSummary(for configuration: EditorConfiguration) -> String {
+ let resolved = configuration.locale
+ guard let requested = Locale.preferredLanguages.first else {
+ return resolved
+ }
+
+ let normalized = requested.replacingOccurrences(of: "_", with: "-").lowercased()
+ if normalized == resolved {
+ return resolved
+ }
+
+ // English ships no bundle of its own — it is the editor's source
+ // language — so describe it as the language being used rather than as
+ // a fallback from something else.
+ if resolved == DemoAppLocale.defaultLocale, DemoAppLocale.isEnglish(requested) {
+ return "\(resolved) — \(requested)"
+ }
+
+ let outcome = resolved == DemoAppLocale.defaultLocale
+ ? "no bundle, using default"
+ : "resolved"
+ return "\(resolved) — \(outcome) from \(requested)"
+ }
+
var preloadSection: some View {
Section {
Button("Prepare Editor") {
@@ -283,6 +314,7 @@ class SitePreparationViewModel {
private static func applyDemoAppDefaults(to configuration: EditorConfiguration) -> EditorConfiguration {
configuration.toBuilder()
.setNativeInserterEnabled(true)
+ .setLocale(DemoAppLocale.current)
.build()
}
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index 808c7034f..dd2a8a7f2 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -213,6 +213,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
controller.delegate = self
webView.navigationDelegate = controller
+ // Declares the editor's language to assistive technology, so it selects
+ // a matching speech voice. The web content declares its own language via
+ // `documentElement.lang`; this covers the native UI presented alongside
+ // it. `accessibilityLanguage` is inherited, including across modal
+ // presentations, so the block inserter and its sheets are covered too.
+ view.accessibilityLanguage = configuration.locale
+
// Set up Lockdown Mode monitoring with foreground detection
lockdownModeMonitor.setup(presentingViewController: self)
@@ -484,6 +491,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
onClose: { [weak self] in self?.notifyInserterClosed() }
)
.environmentObject(htmlPreviewManager)
+ .environment(\.locale, Locale(identifier: configuration.locale))
})
context.viewController = host
diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss
index 274a25841..65851d24a 100644
--- a/src/components/editor-toolbar/style.scss
+++ b/src/components/editor-toolbar/style.scss
@@ -93,7 +93,7 @@ $scroll-indicator-elevation: 32;
}
.gutenberg-kit-editor-toolbar .components-toolbar-group {
- border-right-color: $border-color;
+ border-inline-end-color: $border-color;
min-height: $min-touch-target-size;
// Reset Gutenberg's negative margin that oddly create a gap at the top/bottom
// of the toolbar, rather than extending the button height as intended in
@@ -147,7 +147,7 @@ $scroll-indicator-elevation: 32;
// Style the add block button with rounded black background
.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button {
- margin-left: 8px;
+ margin-inline-start: 8px;
svg {
background: #eae9ec;
diff --git a/src/components/editor-toolbar/use-scroll-indicators.js b/src/components/editor-toolbar/use-scroll-indicators.js
index c16587b73..20ca2b7f7 100644
--- a/src/components/editor-toolbar/use-scroll-indicators.js
+++ b/src/components/editor-toolbar/use-scroll-indicators.js
@@ -1,11 +1,16 @@
/**
* Hook to manage scroll indicator state for horizontally scrollable containers.
*
+ * The `canScroll*` properties describe the physical edges of the container
+ * rather than the start and end of the content, matching the gradients they
+ * drive. Those are anchored with `left`/`right` and do not flip in a
+ * right-to-left layout.
+ *
* @param {Object} scrollRef - React ref to the scrollable container element
* @return {Object} Scroll state with properties:
* - isScrollable: Whether the container has overflow content
- * - canScrollLeft: Whether there's content to the left (not at start)
- * - canScrollRight: Whether there's content to the right (not at end)
+ * - canScrollLeft: Whether there's content hidden past the left edge
+ * - canScrollRight: Whether there's content hidden past the right edge
*/
import { useState, useEffect, useCallback } from '@wordpress/element';
@@ -29,9 +34,24 @@ export function useScrollIndicators( scrollRef ) {
const threshold = 1;
const isScrollable = scrollWidth > clientWidth;
- const canScrollLeft = scrollLeft > threshold;
+
+ // In a right-to-left container `scrollLeft` is `0` at the right edge
+ // and grows negative moving left, so the raw value describes distance
+ // from the start rather than from the left. Normalize to that distance,
+ // then map it back onto the physical edges the gradients are anchored
+ // to, which do not flip with the writing direction.
+ const distanceFromStart = Math.abs( scrollLeft );
+ const distanceFromEnd = scrollWidth - clientWidth - distanceFromStart;
+
+ // Read from the document rather than resolving the element's computed
+ // style, which this would otherwise force on every scroll frame. The
+ // direction is set once at startup and fixed for the editor's lifetime.
+ const isRTL = element.ownerDocument.documentElement.dir === 'rtl';
+
+ const canScrollLeft =
+ ( isRTL ? distanceFromEnd : distanceFromStart ) > threshold;
const canScrollRight =
- scrollLeft + clientWidth < scrollWidth - threshold;
+ ( isRTL ? distanceFromStart : distanceFromEnd ) > threshold;
setScrollState( {
isScrollable,
diff --git a/src/components/editor-toolbar/use-scroll-indicators.test.js b/src/components/editor-toolbar/use-scroll-indicators.test.js
new file mode 100644
index 000000000..8c48432c9
--- /dev/null
+++ b/src/components/editor-toolbar/use-scroll-indicators.test.js
@@ -0,0 +1,151 @@
+/**
+ * External dependencies
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { useScrollIndicators } from './use-scroll-indicators';
+
+/**
+ * Builds a ref to an element with a stubbed scroll geometry.
+ *
+ * jsdom does not lay out content, so `scrollWidth` and `clientWidth` are always
+ * `0` and the hook would see every container as unscrollable. Define them
+ * directly to model an overflowing toolbar.
+ *
+ * @param {Object} geometry Scroll geometry to simulate.
+ * @param {number} geometry.scrollLeft Current scroll offset. Negative in a
+ * right-to-left container.
+ * @param {number} geometry.scrollWidth Total scrollable width.
+ * @param {number} geometry.clientWidth Visible width.
+ * @param {string} geometry.direction Text direction the editor renders in.
+ * Set on the document, which is where the
+ * hook reads it from.
+ *
+ * @return {Object} A React ref pointing at the element.
+ */
+function createScrollRef( {
+ scrollLeft,
+ scrollWidth = 500,
+ clientWidth = 200,
+ direction = 'ltr',
+} ) {
+ const element = document.createElement( 'div' );
+ document.documentElement.dir = direction;
+ document.body.appendChild( element );
+
+ Object.defineProperties( element, {
+ scrollLeft: { value: scrollLeft, configurable: true },
+ scrollWidth: { value: scrollWidth, configurable: true },
+ clientWidth: { value: clientWidth, configurable: true },
+ } );
+
+ return { current: element };
+}
+
+describe( 'useScrollIndicators', () => {
+ afterEach( () => {
+ // The direction is set on the document, so reset it to keep a
+ // right-to-left case from leaking into the next test.
+ document.documentElement.dir = '';
+ } );
+
+ it( 'reports an overflowing container as scrollable', () => {
+ const scrollRef = createScrollRef( { scrollLeft: 0 } );
+ const { result } = renderHook( () => useScrollIndicators( scrollRef ) );
+
+ expect( result.current.isScrollable ).toBe( true );
+ } );
+
+ it( 'reports a container without overflow as not scrollable', () => {
+ const scrollRef = createScrollRef( {
+ scrollLeft: 0,
+ scrollWidth: 200,
+ clientWidth: 200,
+ } );
+ const { result } = renderHook( () => useScrollIndicators( scrollRef ) );
+
+ expect( result.current.isScrollable ).toBe( false );
+ expect( result.current.canScrollLeft ).toBe( false );
+ expect( result.current.canScrollRight ).toBe( false );
+ } );
+
+ describe( 'left-to-right', () => {
+ it( 'hides the left gradient at the start edge', () => {
+ const scrollRef = createScrollRef( { scrollLeft: 0 } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollLeft ).toBe( false );
+ expect( result.current.canScrollRight ).toBe( true );
+ } );
+
+ it( 'shows both gradients mid-scroll', () => {
+ const scrollRef = createScrollRef( { scrollLeft: 150 } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollLeft ).toBe( true );
+ expect( result.current.canScrollRight ).toBe( true );
+ } );
+
+ it( 'hides the right gradient at the end edge', () => {
+ const scrollRef = createScrollRef( { scrollLeft: 300 } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollLeft ).toBe( true );
+ expect( result.current.canScrollRight ).toBe( false );
+ } );
+ } );
+
+ // `scrollLeft` is `0` at the right edge and grows negative moving left, so
+ // the start edge is on the right and the gradients map to the opposite
+ // physical edges from their left-to-right counterparts.
+ describe( 'right-to-left', () => {
+ it( 'hides the right gradient at the start edge', () => {
+ const scrollRef = createScrollRef( {
+ scrollLeft: 0,
+ direction: 'rtl',
+ } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollRight ).toBe( false );
+ expect( result.current.canScrollLeft ).toBe( true );
+ } );
+
+ it( 'shows both gradients mid-scroll', () => {
+ const scrollRef = createScrollRef( {
+ scrollLeft: -150,
+ direction: 'rtl',
+ } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollLeft ).toBe( true );
+ expect( result.current.canScrollRight ).toBe( true );
+ } );
+
+ it( 'hides the left gradient at the end edge', () => {
+ const scrollRef = createScrollRef( {
+ scrollLeft: -300,
+ direction: 'rtl',
+ } );
+ const { result } = renderHook( () =>
+ useScrollIndicators( scrollRef )
+ );
+
+ expect( result.current.canScrollLeft ).toBe( false );
+ expect( result.current.canScrollRight ).toBe( true );
+ } );
+ } );
+} );
diff --git a/src/components/visual-editor/index.jsx b/src/components/visual-editor/index.jsx
index 36a5ecc31..9b62da745 100644
--- a/src/components/visual-editor/index.jsx
+++ b/src/components/visual-editor/index.jsx
@@ -21,6 +21,11 @@ import componentStyles from '@wordpress/components/build-style/style.css?inline'
import blockEditorContentStyles from '@wordpress/block-editor/build-style/content.css?inline';
import blocksStyles from '@wordpress/block-library/build-style/style.css?inline';
import blocksEditorStyles from '@wordpress/block-library/build-style/editor.css?inline';
+// Right-to-left counterparts, generated upstream by `rtlcss`.
+import componentStylesRTL from '@wordpress/components/build-style/style-rtl.css?inline';
+import blockEditorContentStylesRTL from '@wordpress/block-editor/build-style/content-rtl.css?inline';
+import blocksStylesRTL from '@wordpress/block-library/build-style/style-rtl.css?inline';
+import blocksEditorStylesRTL from '@wordpress/block-library/build-style/editor-rtl.css?inline';
/**
* Internal dependencies
@@ -40,6 +45,20 @@ const {
useLayoutStyles,
} = unlock( blockEditorPrivateApis );
+const LTR_CANVAS_STYLES = [
+ componentStyles,
+ blockEditorContentStyles,
+ blocksStyles,
+ blocksEditorStyles,
+];
+
+const RTL_CANVAS_STYLES = [
+ componentStylesRTL,
+ blockEditorContentStylesRTL,
+ blocksStylesRTL,
+ blocksEditorStylesRTL,
+];
+
// Add some styles for alignwide/alignfull Post Content and its children.
const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--global--wide-size); margin-left: auto; margin-right: auto;}
.is-root-container.alignwide:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: var(--wp--style--global--wide-size);}
@@ -83,6 +102,13 @@ const VisualEditor = forwardRef( function VisualEditor( { hideTitle }, ref ) {
};
}, [] );
+ // `configureLocale` resolves the direction onto the document before the
+ // editor renders, and it does not change for the editor's lifetime.
+ const canvasStyles =
+ document.documentElement.dir === 'rtl'
+ ? RTL_CANVAS_STYLES
+ : LTR_CANVAS_STYLES;
+
const styles = useEditorStyles(
// `commonStyles` represent manually added notable styles that are missing.
// The styles likely absent due to them being injected by the WP Admin
@@ -90,10 +116,7 @@ const VisualEditor = forwardRef( function VisualEditor( { hideTitle }, ref ) {
commonStyles,
// Add sensible default styles if theme styles are not present.
hasThemeStyles ? '' : defaultThemeStyles,
- componentStyles,
- blockEditorContentStyles,
- blocksStyles,
- blocksEditorStyles
+ ...canvasStyles
);
const editorClasses = clsx( 'gutenberg-kit-visual-editor', {
diff --git a/src/index.scss b/src/index.scss
index 9836f9a90..21715a83c 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -30,7 +30,7 @@ $baseline-interactive-font-size: 17px;
/* Popover */
.components-popover__header-title {
- padding-left: 20px;
+ padding-inline-start: 20px;
}
.components-popover.is-expanded .components-popover__content {
@@ -67,12 +67,6 @@ $baseline-interactive-font-size: 17px;
min-height: 100vh;
}
- .block-inspector-siderbar {
- background: #f6f6fbff;
- border-left: 0.5px solid #c8c7cc;
- width: 320px;
- }
-
/* Inserter (Mobile Design) */
// Inserter tab buttons
@@ -129,7 +123,7 @@ $baseline-interactive-font-size: 17px;
.block-editor-inserter__panel-title {
font-size: 15px;
font-weight: 600;
- margin-left: 12px;
+ margin-inline-start: 12px;
}
.components-draggable-drag-component-root {
diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js
index 339f8b6a7..92737ffde 100644
--- a/src/utils/editor-environment.js
+++ b/src/utils/editor-environment.js
@@ -16,7 +16,7 @@ import EditorLoadError from '../components/editor-load-error';
import { setLogLevel, error } from './logger';
import { setUpGlobalErrorHandlers } from './global-error-handler';
import { Platform } from './platform';
-import './editor-styles';
+import { injectEditorStyles } from './editor-styles';
/**
* Initialize the bundled editor by loading assets and configuring modules
@@ -31,7 +31,8 @@ export async function setUpEditorEnvironment() {
await awaitGBKitGlobal();
setLogLevelFromGBKit();
initializeFetchInterceptor();
- await configureLocale();
+ const isRTL = await configureLocale();
+ injectEditorStyles( isRTL );
await initializeWordPressGlobals();
await configureApiFetch();
const pluginLoadResult = await loadPluginsIfEnabled();
diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js
index ea6c77008..fc37543ef 100644
--- a/src/utils/editor-environment.test.js
+++ b/src/utils/editor-environment.test.js
@@ -23,6 +23,7 @@ import { configureLocale } from './localization.js';
import { configureApiFetch } from './api-fetch.js';
import { initializeEditor } from './editor.jsx';
import { initializeFetchInterceptor } from './fetch-interceptor.js';
+import { injectEditorStyles } from './editor-styles.js';
vi.mock( './bridge.js' );
vi.mock( './fetch-interceptor.js' );
@@ -61,7 +62,7 @@ describe( 'setUpEditorEnvironment', () => {
awaitGBKitGlobal.mockResolvedValue( undefined );
getGBKit.mockReturnValue( { plugins: false } );
- configureLocale.mockResolvedValue( undefined );
+ configureLocale.mockResolvedValue( false );
initializeWordPressGlobals.mockImplementation( () => {} );
configureApiFetch.mockImplementation( () => {} );
initializeFetchInterceptor.mockImplementation( () => {} );
@@ -91,6 +92,10 @@ describe( 'setUpEditorEnvironment', () => {
return Promise.resolve();
} );
+ injectEditorStyles.mockImplementation( () => {
+ callOrder.push( 'injectEditorStyles' );
+ } );
+
initializeWordPressGlobals.mockImplementation( () => {
callOrder.push( 'loadRemainingGlobals' );
} );
@@ -117,6 +122,7 @@ describe( 'setUpEditorEnvironment', () => {
'awaitGBKitGlobal',
'initializeFetchInterceptor',
'configureLocale',
+ 'injectEditorStyles',
'loadRemainingGlobals',
'configureApiFetch',
'configureAjax',
diff --git a/src/utils/editor-styles.js b/src/utils/editor-styles.js
index 47bba246a..44f2258cd 100644
--- a/src/utils/editor-styles.js
+++ b/src/utils/editor-styles.js
@@ -2,8 +2,77 @@
* WordPress dependencies
*/
// Default styles that are needed for the editor.
-import '@wordpress/components/build-style/style.css';
-import '@wordpress/block-editor/build-style/style.css';
-import '@wordpress/block-library/build-style/editor.css';
-import '@wordpress/format-library/build-style/style.css';
-import '@wordpress/editor/build-style/style.css';
+import componentsStyles from '@wordpress/components/build-style/style.css?inline';
+import blockEditorStyles from '@wordpress/block-editor/build-style/style.css?inline';
+import blockLibraryEditorStyles from '@wordpress/block-library/build-style/editor.css?inline';
+import formatLibraryStyles from '@wordpress/format-library/build-style/style.css?inline';
+import editorStyles from '@wordpress/editor/build-style/style.css?inline';
+
+// Right-to-left counterparts, generated upstream by `rtlcss`.
+import componentsStylesRTL from '@wordpress/components/build-style/style-rtl.css?inline';
+import blockEditorStylesRTL from '@wordpress/block-editor/build-style/style-rtl.css?inline';
+import blockLibraryEditorStylesRTL from '@wordpress/block-library/build-style/editor-rtl.css?inline';
+import formatLibraryStylesRTL from '@wordpress/format-library/build-style/style-rtl.css?inline';
+import editorStylesRTL from '@wordpress/editor/build-style/style-rtl.css?inline';
+
+const LTR_STYLES = [
+ componentsStyles,
+ blockEditorStyles,
+ blockLibraryEditorStyles,
+ formatLibraryStyles,
+ editorStyles,
+];
+
+const RTL_STYLES = [
+ componentsStylesRTL,
+ blockEditorStylesRTL,
+ blockLibraryEditorStylesRTL,
+ formatLibraryStylesRTL,
+ editorStylesRTL,
+];
+
+const STYLE_ELEMENT_ID = 'gutenberg-kit-editor-styles';
+
+/**
+ * Injects the editor stylesheets matching the document's text direction.
+ *
+ * Only one variant is ever inserted. The `-rtl` bundles are full rewrites of
+ * their left-to-right counterparts rather than overrides — across the five
+ * stylesheets roughly 690 selectors appear in both files with conflicting
+ * declarations, and almost none are scoped by a `[dir=rtl]` guard. Loading
+ * both would leave the cascade to resolve those conflicts by source order,
+ * applying one direction to every user regardless of locale.
+ *
+ * WordPress solves this server-side by swapping the enqueued file
+ * (`is_rtl() ? 'style-rtl.css' : 'style.css'`). GutenbergKit ships both
+ * variants in the bundle and selects between them here instead, since the
+ * editor loads a single static `index.html`.
+ *
+ * The element is inserted before the first stylesheet link rather than
+ * appended. These stylesheets are the base layer that GutenbergKit's own
+ * styles build on, and several selectors tie on specificity across the two
+ * (`.gutenberg-kit .components-button` against
+ * `.editor-visual-editor .components-button`, both `0,2,0`). Ties resolve by
+ * source order, so appending would silently hand those to WordPress. Building
+ * these as a side-effect import placed them first; inserting first preserves
+ * that.
+ *
+ * @param {boolean} isRTL Whether the editor renders right-to-left.
+ *
+ * @return {void}
+ */
+export function injectEditorStyles( isRTL ) {
+ const existing = document.getElementById( STYLE_ELEMENT_ID );
+ if ( existing ) {
+ existing.remove();
+ }
+
+ const element = document.createElement( 'style' );
+ element.id = STYLE_ELEMENT_ID;
+ element.textContent = ( isRTL ? RTL_STYLES : LTR_STYLES ).join( '\n' );
+
+ const firstStylesheet = document.head.querySelector(
+ 'link[rel="stylesheet"], style'
+ );
+ document.head.insertBefore( element, firstStylesheet );
+}
diff --git a/src/utils/editor-styles.test.js b/src/utils/editor-styles.test.js
new file mode 100644
index 000000000..0759f867f
--- /dev/null
+++ b/src/utils/editor-styles.test.js
@@ -0,0 +1,129 @@
+/**
+ * External dependencies
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import { injectEditorStyles } from './editor-styles';
+
+// Vitest runs with `css: false`, so `?inline` imports resolve to empty strings
+// and the real stylesheets never reach the module. Stub each one with an
+// identifiable marker so the direction selection is observable.
+vi.mock( '@wordpress/components/build-style/style.css?inline', () => ( {
+ default: '.ltr-components{}',
+} ) );
+vi.mock( '@wordpress/block-editor/build-style/style.css?inline', () => ( {
+ default: '.ltr-block-editor{}',
+} ) );
+vi.mock( '@wordpress/block-library/build-style/editor.css?inline', () => ( {
+ default: '.ltr-block-library{}',
+} ) );
+vi.mock( '@wordpress/format-library/build-style/style.css?inline', () => ( {
+ default: '.ltr-format-library{}',
+} ) );
+vi.mock( '@wordpress/editor/build-style/style.css?inline', () => ( {
+ default: '.ltr-editor{}',
+} ) );
+
+vi.mock( '@wordpress/components/build-style/style-rtl.css?inline', () => ( {
+ default: '.rtl-components{}',
+} ) );
+vi.mock( '@wordpress/block-editor/build-style/style-rtl.css?inline', () => ( {
+ default: '.rtl-block-editor{}',
+} ) );
+vi.mock( '@wordpress/block-library/build-style/editor-rtl.css?inline', () => ( {
+ default: '.rtl-block-library{}',
+} ) );
+vi.mock( '@wordpress/format-library/build-style/style-rtl.css?inline', () => ( {
+ default: '.rtl-format-library{}',
+} ) );
+vi.mock( '@wordpress/editor/build-style/style-rtl.css?inline', () => ( {
+ default: '.rtl-editor{}',
+} ) );
+
+const STYLE_ELEMENT_ID = 'gutenberg-kit-editor-styles';
+
+const getStyleElement = () => document.getElementById( STYLE_ELEMENT_ID );
+
+describe( 'injectEditorStyles', () => {
+ beforeEach( () => {
+ document.head.innerHTML = '';
+ } );
+
+ it( 'injects a single style element into the document head', () => {
+ injectEditorStyles( false );
+
+ const element = getStyleElement();
+ expect( element ).not.toBeNull();
+ expect( element.tagName ).toBe( 'STYLE' );
+ expect( element.parentElement ).toBe( document.head );
+ } );
+
+ // Several selectors tie on specificity between these stylesheets and
+ // GutenbergKit's own, so the cascade resolves them by source order.
+ it( 'injects the styles before the existing stylesheets', () => {
+ const link = document.createElement( 'link' );
+ link.rel = 'stylesheet';
+ link.href = 'index.css';
+ document.head.appendChild( link );
+
+ injectEditorStyles( false );
+
+ expect( getStyleElement().nextElementSibling ).toBe( link );
+ } );
+
+ it( 'injects the styles when the head has no stylesheets', () => {
+ injectEditorStyles( false );
+
+ expect( getStyleElement().parentElement ).toBe( document.head );
+ } );
+
+ it( 'injects the left-to-right stylesheets in cascade order', () => {
+ injectEditorStyles( false );
+
+ expect( getStyleElement().textContent ).toBe(
+ [
+ '.ltr-components{}',
+ '.ltr-block-editor{}',
+ '.ltr-block-library{}',
+ '.ltr-format-library{}',
+ '.ltr-editor{}',
+ ].join( '\n' )
+ );
+ } );
+
+ it( 'injects the right-to-left stylesheets in cascade order', () => {
+ injectEditorStyles( true );
+
+ expect( getStyleElement().textContent ).toBe(
+ [
+ '.rtl-components{}',
+ '.rtl-block-editor{}',
+ '.rtl-block-library{}',
+ '.rtl-format-library{}',
+ '.rtl-editor{}',
+ ].join( '\n' )
+ );
+ } );
+
+ // The `-rtl` bundles are full rewrites rather than overrides, so injecting
+ // both would let source order decide which direction every user gets.
+ it( 'injects only one direction at a time', () => {
+ injectEditorStyles( true );
+
+ const content = getStyleElement().textContent;
+ expect( content ).toContain( '.rtl-components{}' );
+ expect( content ).not.toContain( '.ltr-components{}' );
+ } );
+
+ it( 'replaces the previous styles rather than accumulating them', () => {
+ injectEditorStyles( false );
+ injectEditorStyles( true );
+
+ expect(
+ document.querySelectorAll( `#${ STYLE_ELEMENT_ID }` )
+ ).toHaveLength( 1 );
+ } );
+} );
diff --git a/src/utils/localization.js b/src/utils/localization.js
index 9595c4b27..efae94ff5 100644
--- a/src/utils/localization.js
+++ b/src/utils/localization.js
@@ -15,14 +15,93 @@ const DEFAULT_LOCALE = 'en';
// loader map below is always in sync with what we actually ship.
const TRANSLATION_MODULES = import.meta.glob( '../translations/*.json' );
+// Right-to-left locales among the bundles we ship. Direction is a fixed
+// property of a language, and the native side has already resolved the
+// consumer-supplied locale to one of these tags before it reaches JS, so
+// deriving direction here always agrees with the translations we load.
+//
+// Kept as base language tags: no regional bundle we ship (`ar`, `fa`, `he`,
+// `ur` have none) splits across directions, and matching on the base tag
+// keeps this correct if a regional RTL bundle is added later.
+const RTL_LOCALES = new Set( [ 'ar', 'fa', 'he', 'ur' ] );
+
+// The key `@wordpress/i18n` reads for `isRTL()`, which resolves to
+// `_x( 'ltr', 'text direction' )`. The `\u0004` escape is the gettext
+// context separator joining a string's context to its msgid; written as an
+// escape so the control character stays visible in source.
+const TEXT_DIRECTION_KEY = 'text direction\u0004ltr';
+
/**
* Initializes i18n support for the editor.
*
- * @return {Promise} A promise that resolves when i18n is initialized.
+ * @return {Promise} A promise resolving to whether the configured
+ * locale renders right-to-left, so callers apply the same direction this
+ * resolved rather than deriving it a second time.
*/
export async function configureLocale() {
const { locale = DEFAULT_LOCALE } = getGBKit();
await loadTranslations( locale );
+ return configureTextDirection( locale );
+}
+
+/**
+ * Determines whether a locale is written right-to-left.
+ *
+ * @param {string} locale The locale to check.
+ *
+ * @return {boolean} Whether the locale is right-to-left.
+ */
+export function isRTLLocale( locale ) {
+ if ( ! locale ) {
+ return false;
+ }
+
+ // Match on the base language subtag so regional variants (e.g. `ar-dz`)
+ // resolve correctly even though we don't currently ship any.
+ const [ language ] = locale.toLowerCase().split( /[-_]/ );
+ return RTL_LOCALES.has( language );
+}
+
+/**
+ * Applies the locale's text direction to the document and to `@wordpress/i18n`.
+ *
+ * In WordPress, core renders `` and ``, and
+ * populates the `text direction` string that backs `isRTL()`. GutenbergKit
+ * loads a static `index.html`, so nothing performs that role and the editor
+ * would otherwise render every locale as English left-to-right.
+ *
+ * Both halves matter. The DOM attributes drive CSS logical properties, bidi
+ * text runs, and native spellcheck/screen-reader behavior. The `setLocaleData`
+ * entry drives `isRTL()`, which Gutenberg components call at runtime to pick
+ * icons, accessibility labels, keyboard navigation, and drop-zone geometry —
+ * none of which CSS can correct.
+ *
+ * The translation bundles we ship come from the `wp-plugins/gutenberg` GlotPress
+ * project, which does not carry the `text direction` string (it belongs to
+ * core), so the entry is injected here rather than read from the bundle.
+ *
+ * @param {string} locale The locale in use.
+ *
+ * @return {boolean} Whether the locale renders right-to-left.
+ */
+function configureTextDirection( locale ) {
+ const isRTL = isRTLLocale( locale );
+ const direction = isRTL ? 'rtl' : 'ltr';
+
+ // Back `isRTL()` for Gutenberg's runtime direction checks.
+ setLocaleData( { [ TEXT_DIRECTION_KEY ]: [ direction ] } );
+
+ const { documentElement, body } = document;
+
+ documentElement.lang = locale;
+ documentElement.dir = direction;
+
+ // Some Gutenberg styles key off `body.rtl` rather than `[dir=rtl]`.
+ body?.classList.toggle( 'rtl', isRTL );
+
+ debug( `Text direction configured as "${ direction }" for "${ locale }"` );
+
+ return isRTL;
}
/**
diff --git a/src/utils/localization.test.js b/src/utils/localization.test.js
new file mode 100644
index 000000000..279108e39
--- /dev/null
+++ b/src/utils/localization.test.js
@@ -0,0 +1,129 @@
+/**
+ * External dependencies
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/**
+ * WordPress dependencies
+ */
+import { setLocaleData } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { configureLocale, isRTLLocale } from './localization';
+import { getGBKit } from './bridge';
+
+vi.mock( './bridge' );
+vi.mock( './logger' );
+
+vi.mock( '@wordpress/i18n', () => ( {
+ setLocaleData: vi.fn(),
+} ) );
+
+// The gettext context separator joining a string's context to its msgid.
+const TEXT_DIRECTION_KEY = `text direction${ String.fromCharCode( 4 ) }ltr`;
+
+describe( 'isRTLLocale', () => {
+ it.each( [ 'ar', 'fa', 'he', 'ur' ] )(
+ 'identifies %s as right-to-left',
+ ( locale ) => {
+ expect( isRTLLocale( locale ) ).toBe( true );
+ }
+ );
+
+ it.each( [ 'en', 'fr', 'ja', 'pt-br', 'zh-cn', 'nl-be' ] )(
+ 'identifies %s as left-to-right',
+ ( locale ) => {
+ expect( isRTLLocale( locale ) ).toBe( false );
+ }
+ );
+
+ it( 'matches on the base language subtag for regional variants', () => {
+ // No regional RTL bundle ships today, but direction is a property of
+ // the language, so a future `ar-dz` bundle must not regress to LTR.
+ expect( isRTLLocale( 'ar-dz' ) ).toBe( true );
+ expect( isRTLLocale( 'ar_DZ' ) ).toBe( true );
+ } );
+
+ it( 'is case insensitive', () => {
+ expect( isRTLLocale( 'AR' ) ).toBe( true );
+ expect( isRTLLocale( 'He' ) ).toBe( true );
+ } );
+
+ it( 'treats missing locales as left-to-right', () => {
+ expect( isRTLLocale( undefined ) ).toBe( false );
+ expect( isRTLLocale( '' ) ).toBe( false );
+ } );
+} );
+
+describe( 'configureLocale', () => {
+ beforeEach( () => {
+ vi.clearAllMocks();
+ document.documentElement.removeAttribute( 'lang' );
+ document.documentElement.removeAttribute( 'dir' );
+ document.body.classList.remove( 'rtl' );
+ } );
+
+ it( 'applies right-to-left direction for an RTL locale', async () => {
+ getGBKit.mockReturnValue( { locale: 'ar' } );
+
+ await configureLocale();
+
+ expect( document.documentElement.dir ).toBe( 'rtl' );
+ expect( document.documentElement.lang ).toBe( 'ar' );
+ expect( document.body.classList.contains( 'rtl' ) ).toBe( true );
+ } );
+
+ it( 'applies left-to-right direction for an LTR locale', async () => {
+ getGBKit.mockReturnValue( { locale: 'fr' } );
+
+ await configureLocale();
+
+ expect( document.documentElement.dir ).toBe( 'ltr' );
+ expect( document.documentElement.lang ).toBe( 'fr' );
+ expect( document.body.classList.contains( 'rtl' ) ).toBe( false );
+ } );
+
+ it( 'defaults to English left-to-right when no locale is provided', async () => {
+ getGBKit.mockReturnValue( {} );
+
+ await configureLocale();
+
+ expect( document.documentElement.dir ).toBe( 'ltr' );
+ expect( document.documentElement.lang ).toBe( 'en' );
+ } );
+
+ it( 'removes a stale rtl body class when switching to an LTR locale', async () => {
+ document.body.classList.add( 'rtl' );
+ getGBKit.mockReturnValue( { locale: 'en' } );
+
+ await configureLocale();
+
+ expect( document.body.classList.contains( 'rtl' ) ).toBe( false );
+ } );
+
+ // `isRTL()` resolves to `_x( 'ltr', 'text direction' )`. The bundles we
+ // fetch from the `wp-plugins/gutenberg` GlotPress project don't carry that
+ // string — it belongs to core — so it must be injected for the Gutenberg
+ // components that branch on direction at runtime.
+ it( 'injects the text direction string that backs isRTL()', async () => {
+ getGBKit.mockReturnValue( { locale: 'he' } );
+
+ await configureLocale();
+
+ expect( setLocaleData ).toHaveBeenCalledWith( {
+ [ TEXT_DIRECTION_KEY ]: [ 'rtl' ],
+ } );
+ } );
+
+ it( 'injects ltr for left-to-right locales', async () => {
+ getGBKit.mockReturnValue( { locale: 'de' } );
+
+ await configureLocale();
+
+ expect( setLocaleData ).toHaveBeenCalledWith( {
+ [ TEXT_DIRECTION_KEY ]: [ 'ltr' ],
+ } );
+ } );
+} );