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' ], + } ); + } ); +} );