Skip to content

[6.x] Fix control panel text direction on RTL sites#15023

Merged
jasonvarga merged 20 commits into
6.xfrom
content-text-direction
Jul 17, 2026
Merged

[6.x] Fix control panel text direction on RTL sites#15023
jasonvarga merged 20 commits into
6.xfrom
content-text-direction

Conversation

@jasonvarga

@jasonvarga jasonvarga commented Jul 16, 2026

Copy link
Copy Markdown
Member

This refines the text-direction behavior introduced in #13024 ("Publish container text direction"). That PR applied the site/content's dir to the whole field — including its chrome (labels, buttons, table/grid structure, toolbars). On an RTL site edited from an LTR Control Panel (or vice versa), that flips UI elements that should always follow the CP's own direction, not the content's.

What changed

  • Fieldtype chrome (labels, instructions, errors, buttons, toolbars, tabs, table/grid structure, and container/set headers — including Bard set headers) now follows the Control Panel UI direction, not the site/content direction.
  • Editable values — text, textarea, slug, Bard prose, and table/array/list cells — follow the site content direction.
  • Content direction derives from the site direction, independent of the CP direction — so an RTL site edited from an LTR Control Panel keeps its values RTL. Where there is no site to resolve (user forms, wizards, config forms), it falls back to the CP UI direction.

This is a visual-only change and only manifests where content and UI direction diverge (e.g. an RTL site edited from an LTR Control Panel, or vice versa).

Markdown fieldtype (intentional exception)

The Markdown editor stays LTR always — it's a source editor that commonly holds code, tables, links and HTML, which are far more legible LTR. This honors the prior deliberate decision (#10931 tried RTL; reverted in #10992). It also restores an upstream .CodeMirror { direction: ltr } base rule the vendored CSS copy had dropped, so the editor no longer inherits the CP direction. The Markdown preview pane now follows the content direction (it renders the final prose, so RTL display is correct there; code blocks within it stay LTR).

New public API

Two composables and a wrapper component are now exported via @statamic/cms/ui (so they import alongside the other UI components you'd use them with):

  • useUiDirection() — reactive Control Panel UI direction, kept live via a MutationObserver on <html dir>.
  • useContentDirection() — reactive content direction for the current publish container, falling back to UI direction outside of one.
  • <ContentDirection> — applies useContentDirection()'s value via dir. Built on reka-ui's Primitive, so it accepts as (default div) and as-child to change or drop the wrapper element for grid/flex/table-cell layouts.

The shared @ui <Tabs> and <Field> components also gained an optional dir prop, defaulting to the CP UI direction — so any <Tabs> now follows the CP direction by default (relevant if an addon uses these primitives directly).

Addon developers: if your fieldtype renders free-form text, it now defaults to the UI direction like everything else. If you want a value element to follow content direction instead (matching core text/textarea/bard fieldtypes), opt it in with useContentDirection() or wrap it in <ContentDirection> (both from @statamic/cms/ui).

reka-ui's TabsRoot defaults to dir="ltr", forcing the tab-panel
subtree LTR even when the CP is RTL. Default the dir prop to
useUiDirection()'s direction while keeping it overridable.
The UI-direction reset only wrapped the fieldtype component, not the
field's label/instructions/errors. Nested fields (e.g. inside a Bard
set) inherited the ambient content direction for their chrome instead
of the UI direction. Move the dir reset onto the Field component
itself so it covers the whole field, while leaf fieldtypes still opt
back into content direction on their own value inputs.
The Set node view renders inside the Bard editor's content-direction
subtree, so its header chrome (drag handle, title, instructions icon,
switch, options dropdown) inherited content direction instead of UI
direction. Bind the set container's dir to useUiDirection() so the
chrome follows UI direction while nested field values still opt into
content direction on their own inputs.
CodeMirror's 'direction' option only sets an internal CSS class for
per-line text bidi and never puts a dir attribute on its own wrapping
element, so the write-mode editor surface relied on ambient
inheritance for everything else (cursor rendering, layout). Bind
:dir="contentDirection" directly on the CodeMirror mount element,
matching what the preview pane already does.
CodeMirror 5 doesn't inherit an ancestor's dir attribute — it only
honors its own 'direction' option, applied via setOption. The prior
fix bound :dir on the mount wrapper, which is inert for CodeMirror's
own rendering and incorrectly applied content direction to the
floating toolbar chrome that lives in the same wrapper. Replace it
with a watcher that calls codemirror.setOption('direction', ...)
whenever contentDirection resolves or changes.
The vendored resources/css/vendors/codemirror.css sets
'.CodeMirror-rtl pre { direction: ltr; }', which deviates from
upstream CodeMirror 5 (direction: rtl) and forces every line back to
LTR even when the .CodeMirror-rtl class is present. Add a
higher-specificity override scoped to .markdown-fieldtype rather
than hand-editing the vendored file.
Container.vue's direction computed fell back to document.documentElement.dir
(the CP/UI direction) whenever the resolved site had no direction, coupling
content direction to the CP. This made field values render RTL whenever
EITHER the CP or the content was RTL, instead of following the site alone.
Site::direction() always resolves for a matched site, so the only case this
fallback affected was an unresolved site handle — default that to 'ltr'
instead of the CP direction.
CodeMirror only marks rtl lines explicitly (.CodeMirror-rtl, todo 65's
CSS); its ltr lines have no explicit direction and just inherit from
the nearest ancestor with a dir attribute. Since the <Field> wrapper
carries the CP/UI direction (todo 61), an ltr markdown value nested in
an rtl CP inherited the CP's rtl instead of staying ltr. Set dir
directly on CodeMirror's own wrapper element for both directions, at
init and in the existing contentDirection watcher, so the editor is
never at the mercy of ambient inheritance.
Per PR #10931 and its revert #10992, the markdown editor is treated as
source/code-adjacent and is intentionally always ltr, regardless of
content direction. Revert the editor-direction machinery added by the
recent RTL work (CodeMirror 'direction' init option, the
contentDirection watcher, the explicit wrapper dir, and the
.CodeMirror-rtl override), and restore the upstream base .CodeMirror
rule's 'direction: ltr' that the vendored copy had dropped, so the
editor reliably stays ltr instead of inheriting the CP direction from
the <Field> wrapper. Only the rendered .markdown-preview pane
continues to follow content direction; its code blocks stay ltr via
the existing .markdown-preview pre rule.
- Container.vue: fall back to the UI direction (not a literal 'ltr')
  when the site can't be resolved, so site-less publish containers
  (user profile forms, wizards, config forms) don't force LTR inputs
  in an RTL CP.
- ContentDirection.vue: rewrite on reka-ui's Primitive so addons can
  pass as-child/as instead of always getting an unconditional wrapper
  div, which was breaking grid/flex/table-cell layouts.
- TextFieldtype.vue: remove the dead :direction="config.direction"
  binding (no such config option, and Input.vue doesn't consume a
  direction prop); the real handling is the adjacent input-attrs dir
  binding.
useUiDirection's module-level dir ref is initialized at import time
and only updates via an awaited MutationObserver microtask, so tests
that set document.documentElement.dir without flushing it relied on
an earlier test in the same file having already done so. Move the
attribute set + microtask flush into a shared beforeEach (with an
afterEach reset) in ContentDirection.test.js and Container.test.js so
every test is self-contained and passes with test.only.

ui-direction.test.js already resets modules and re-imports the
composable fresh per test, so it was already order-independent;
verified via test.only, no changes needed there.
The ui/index.js export-ordering hack existed because content-direction.js
imported injectContainerContext from the heavy Publish/Container.vue,
so evaluating the ContentDirection export early in the barrel dragged
Container.vue's whole component graph in mid-evaluation and crashed on
a circular-import TDZ (Cannot access 'Dropdown' before initialization).

- Extract the context tuple into Publish/context.js; Container.vue now
  imports and re-exports it for the existing direct importers.
- content-direction.js imports injectContainerContext from the new
  light context.js module instead of Container.vue.
- Move ContentDirection.vue into Publish/, since it's conceptually
  part of the publish container's public surface.
- ui/index.js no longer needs the ordering workaround; ContentDirection
  is now exported from its normal alphabetical position.
Move useUiDirection and useContentDirection from the core package
entry to the ui subpath, alongside ContentDirection, so the whole
direction toolkit is cohesive on @statamic/cms/ui instead of split
across core and ui.

To keep components/ui/index.js, bootstrap/cms/ui.js, and
packages/cms/src/ui.js in sync (Package.test.js asserts all three
expose the same export set), the composables are re-exported from the
components/ui barrel itself, following the existing registerIconSet
precedent for non-component utility exports living there. This is
safe now that content-direction.js depends on the light
Publish/context.js rather than the heavy Container.vue.
@jasonvarga jasonvarga changed the title [6.x] Refine publish container text direction to distinguish chrome from content [6.x] Fix control panel text direction on RTL sites Jul 17, 2026
@jasonvarga
jasonvarga merged commit 1904c28 into 6.x Jul 17, 2026
24 checks passed
@jasonvarga
jasonvarga deleted the content-text-direction branch July 17, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant