From 13a2a477a86e9abfe67f50e15e3fa6be1e50de7c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 17:29:06 -0400 Subject: [PATCH 01/20] Add useUiDirection composable --- resources/js/composables/ui-direction.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 resources/js/composables/ui-direction.js diff --git a/resources/js/composables/ui-direction.js b/resources/js/composables/ui-direction.js new file mode 100644 index 00000000000..2d24f855e98 --- /dev/null +++ b/resources/js/composables/ui-direction.js @@ -0,0 +1,15 @@ +import { computed, ref } from 'vue'; + +const dir = ref(typeof document !== 'undefined' ? document.documentElement.dir || 'ltr' : 'ltr'); + +if (typeof document !== 'undefined') { + new MutationObserver(() => { + dir.value = document.documentElement.dir || 'ltr'; + }).observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] }); +} + +export function useUiDirection() { + return { + direction: computed(() => dir.value), + }; +} From 5240d81533120d091c44b5b0452fb864c5a71249 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 17:32:39 -0400 Subject: [PATCH 02/20] Add useContentDirection composable and ContentDirection component --- resources/js/components/ui/ContentDirection.vue | 11 +++++++++++ resources/js/composables/content-direction.js | 12 ++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 resources/js/components/ui/ContentDirection.vue create mode 100644 resources/js/composables/content-direction.js diff --git a/resources/js/components/ui/ContentDirection.vue b/resources/js/components/ui/ContentDirection.vue new file mode 100644 index 00000000000..a4ffd5b5804 --- /dev/null +++ b/resources/js/components/ui/ContentDirection.vue @@ -0,0 +1,11 @@ + + + diff --git a/resources/js/composables/content-direction.js b/resources/js/composables/content-direction.js new file mode 100644 index 00000000000..56c78545d1d --- /dev/null +++ b/resources/js/composables/content-direction.js @@ -0,0 +1,12 @@ +import { computed, unref } from 'vue'; +import { injectContainerContext } from '@/components/ui/Publish/Container.vue'; +import { useUiDirection } from './ui-direction'; + +export function useContentDirection() { + const container = injectContainerContext(); + const { direction: uiDirection } = useUiDirection(); + + return { + direction: computed(() => (container ? unref(container.direction) : uiDirection.value)), + }; +} From e1fd7d76f17704b66fd06deaf023da9744171226 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 17:36:10 -0400 Subject: [PATCH 03/20] Use UI direction for fieldtype chrome wrapper in Field.vue --- resources/js/components/ui/Publish/Field.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue index 0f428d496d8..22cec1a99b3 100644 --- a/resources/js/components/ui/Publish/Field.vue +++ b/resources/js/components/ui/Publish/Field.vue @@ -2,6 +2,7 @@ import { computed, useTemplateRef, watch, ref, inject } from 'vue'; import { injectContainerContext } from './Container.vue'; import { injectFieldsContext } from './FieldsProvider.vue'; +import { useUiDirection } from '@/composables/ui-direction'; import { Avatar, Field, @@ -46,7 +47,7 @@ const { focusField, blurField, container, - direction, + direction: contentDirection, } = injectContainerContext(); const { fieldPathPrefix: injectedFieldPathPrefix, @@ -55,6 +56,8 @@ const { asConfig: fieldsAsConfig, } = injectFieldsContext(); +const { direction } = useUiDirection(); + const asConfig = computed(() => fieldsAsConfig.value ?? containerAsConfig.value ?? false); const fieldPathPrefix = computed(() => props.fieldPathPrefix || injectedFieldPathPrefix.value); const metaPathPrefix = computed(() => props.metaPathPrefix || injectedMetaPathPrefix.value); From 30d7d0257a6f8dc10fa6b52c1157a71039f17ed1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 19:32:41 -0400 Subject: [PATCH 04/20] Opt leaf value fieldtypes into content direction --- resources/js/components/fieldtypes/ArrayFieldtype.vue | 11 +++++++++++ resources/js/components/fieldtypes/ListFieldtype.vue | 8 ++++++++ resources/js/components/fieldtypes/SlugFieldtype.vue | 8 ++++++++ resources/js/components/fieldtypes/TableFieldtype.vue | 8 ++++++++ resources/js/components/fieldtypes/TextFieldtype.vue | 4 ++++ .../js/components/fieldtypes/TextareaFieldtype.vue | 8 ++++++++ .../js/components/fieldtypes/bard/BardFieldtype.vue | 9 ++++++++- .../fieldtypes/markdown/MarkdownFieldtype.vue | 10 +++++++++- 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/resources/js/components/fieldtypes/ArrayFieldtype.vue b/resources/js/components/fieldtypes/ArrayFieldtype.vue index 17cf585429a..5c4184576d1 100644 --- a/resources/js/components/fieldtypes/ArrayFieldtype.vue +++ b/resources/js/components/fieldtypes/ArrayFieldtype.vue @@ -24,6 +24,7 @@ :key="element._id" :id="fieldId + '__' + element.key" :readonly="isReadOnly" + :input-attrs="{ dir: contentDirection }" /> @@ -41,6 +42,7 @@ :id="fieldId + '__' + element.key" v-model="data[index].value" :readonly="isReadOnly" + :dir="contentDirection" /> @@ -71,12 +73,14 @@ @@ -107,6 +111,7 @@ import Fieldtype from './Fieldtype.vue'; import { SortableList, SortableHelpers } from '../sortable/Sortable'; import { Button } from '@/components/ui'; +import { useContentDirection } from '@/composables/content-direction'; export default { mixins: [Fieldtype, SortableHelpers], @@ -116,6 +121,12 @@ export default { Button, }, + setup() { + const { direction: contentDirection } = useContentDirection(); + + return { contentDirection }; + }, + data() { const keys = Object.keys(this.value || {}); const selectedKey = keys.length > 0 ? keys[0] : null; diff --git a/resources/js/components/fieldtypes/ListFieldtype.vue b/resources/js/components/fieldtypes/ListFieldtype.vue index 03be98e115a..56c250e9f6c 100644 --- a/resources/js/components/fieldtypes/ListFieldtype.vue +++ b/resources/js/components/fieldtypes/ListFieldtype.vue @@ -23,6 +23,7 @@ class="!inset-shadow-none focus:!inset-shadow-none" v-model="element.value" :readonly="isReadOnly" + :input-attrs="{ dir: contentDirection }" @blur="focused = false" @focus="editItemWithoutFocusing(index)" @keydown.enter.prevent="nextItem" @@ -58,6 +59,7 @@ import Fieldtype from './Fieldtype.vue'; import { SortableList, SortableHelpers } from '../sortable/Sortable'; import { Button } from '@/components/ui'; +import { useContentDirection } from '@/composables/content-direction'; export default { mixins: [Fieldtype, SortableHelpers], @@ -67,6 +69,12 @@ export default { Button, }, + setup() { + const { direction: contentDirection } = useContentDirection(); + + return { contentDirection }; + }, + data() { return { data: [], diff --git a/resources/js/components/fieldtypes/SlugFieldtype.vue b/resources/js/components/fieldtypes/SlugFieldtype.vue index 01f73e15e96..d9dda4158e7 100644 --- a/resources/js/components/fieldtypes/SlugFieldtype.vue +++ b/resources/js/components/fieldtypes/SlugFieldtype.vue @@ -19,6 +19,7 @@ :read-only="isReadOnly" :name="slug" :disabled="config.disabled" + :input-attrs="{ dir: contentDirection }" @focus="$emit('focus')" @blur="$emit('blur')" > @@ -40,6 +41,7 @@ import { data_get } from '../../bootstrap/globals'; import Fieldtype from './Fieldtype.vue'; import { Input, Button, Icon } from '@/components/ui'; +import { useContentDirection } from '@/composables/content-direction'; export default { mixins: [Fieldtype], @@ -50,6 +52,12 @@ export default { Icon, }, + setup() { + const { direction: contentDirection } = useContentDirection(); + + return { contentDirection }; + }, + data() { return { slug: this.value, diff --git a/resources/js/components/fieldtypes/TableFieldtype.vue b/resources/js/components/fieldtypes/TableFieldtype.vue index 088a14631a2..beec9ba869d 100644 --- a/resources/js/components/fieldtypes/TableFieldtype.vue +++ b/resources/js/components/fieldtypes/TableFieldtype.vue @@ -43,6 +43,7 @@ @@ -88,6 +89,7 @@ diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index c3f97383447..e682303b6f1 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -116,7 +116,7 @@
- + @@ -184,6 +185,7 @@ import AssetSelector from '../../assets/Selector.vue'; import Uploader from '../../assets/Uploader.vue'; import Uploads from '../../assets/Uploads.vue'; import MarkdownToolbar from './MarkdownToolbar.vue'; +import { useContentDirection } from '@/composables/content-direction'; // Keymaps import 'codemirror/keymap/sublime'; @@ -240,6 +242,12 @@ export default { Stack, }, + setup() { + const { direction: contentDirection } = useContentDirection(); + + return { contentDirection }; + }, + data() { return { data: this.value || '', @@ -654,7 +662,7 @@ export default { mode: 'gfm', dragDrop: false, keyMap: 'sublime', - direction: document.querySelector('html').getAttribute('dir') ?? 'ltr', + direction: self.contentDirection, lineWrapping: true, viewportMargin: Infinity, tabindex: 0, From c3a13bc489db98d6f05048b9e8e6776be5aa2c7b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 19:38:14 -0400 Subject: [PATCH 05/20] Export useUiDirection, useContentDirection, and ContentDirection via @statamic/cms --- packages/cms/src/index.js | 2 ++ packages/cms/src/ui.js | 1 + resources/js/bootstrap/cms/core.js | 2 ++ resources/js/bootstrap/cms/ui.js | 1 + resources/js/components/ui/index.js | 1 + resources/js/tests/Package.test.js | 3 +++ 6 files changed, 10 insertions(+) diff --git a/packages/cms/src/index.js b/packages/cms/src/index.js index 8b30e9f288b..7abf50cd41d 100644 --- a/packages/cms/src/index.js +++ b/packages/cms/src/index.js @@ -22,4 +22,6 @@ export const { debounce, deepClone, resetValuesFromResponse, + useContentDirection, + useUiDirection, } = __STATAMIC__.core; diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index a84b8d0e411..3cdba1e5f5c 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -17,6 +17,7 @@ export const { Combobox, CommandPaletteItem, ConfirmationModal, + ContentDirection, Context, ContextFooter, ContextHeader, diff --git a/resources/js/bootstrap/cms/core.js b/resources/js/bootstrap/cms/core.js index 05070c7d6c8..0b65ad644eb 100644 --- a/resources/js/bootstrap/cms/core.js +++ b/resources/js/bootstrap/cms/core.js @@ -19,3 +19,5 @@ export { default as resetValuesFromResponse } from '../../util/resetValuesFromRe export { requireElevatedSession, requireElevatedSessionIf } from '../../components/elevated-sessions'; export { default as clone, deepClone } from '../../util/clone.js'; export { default as debounce } from '../../util/debounce.js'; +export { useContentDirection } from '../../composables/content-direction.js'; +export { useUiDirection } from '../../composables/ui-direction.js'; diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index 2b8a3e7cd1e..82f78014ee6 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -17,6 +17,7 @@ export { Combobox, CommandPaletteItem, ConfirmationModal, + ContentDirection, Context, ContextFooter, ContextHeader, diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index d19fd06b96a..13243450db3 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -108,6 +108,7 @@ export { default as PublishLocalizations } from "./Publish/Localizations.vue"; export { default as PublishSections } from "./Publish/Sections.vue"; export { default as PublishTabs } from "./Publish/Tabs.vue"; export { default as TabProvider } from "./Publish/TabProvider.vue"; +export { default as ContentDirection } from './ContentDirection.vue'; export { default as StatusIndicator } from "./StatusIndicator.vue"; export { default as Widget } from "./Widget.vue"; export { default as Listing } from "./Listing/Listing.vue"; diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 4aa0bbc5e71..b862d4128c3 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -40,6 +40,8 @@ it('exports core', async () => { 'requireElevatedSession', 'requireElevatedSessionIf', 'resetValuesFromResponse', + 'useContentDirection', + 'useUiDirection', ]; expect(Object.keys(modules.core).toSorted()).toEqual(expected) @@ -138,6 +140,7 @@ it('exports ui', async () => { 'CodeEditor', 'Combobox', 'ConfirmationModal', + 'ContentDirection', 'Context', 'ContextFooter', 'ContextHeader', From 0fbfa9eaea32b15fb74dd25189b2278805b6c57a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 19:42:51 -0400 Subject: [PATCH 06/20] Add Vitest coverage for UI/content direction --- .../fieldtypes/TableFieldtype.test.js | 37 +++++++++++++++++++ resources/js/tests/ui-direction.test.js | 24 ++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 resources/js/tests/components/fieldtypes/TableFieldtype.test.js create mode 100644 resources/js/tests/ui-direction.test.js diff --git a/resources/js/tests/components/fieldtypes/TableFieldtype.test.js b/resources/js/tests/components/fieldtypes/TableFieldtype.test.js new file mode 100644 index 00000000000..db85e9d2a9a --- /dev/null +++ b/resources/js/tests/components/fieldtypes/TableFieldtype.test.js @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import { computed } from 'vue'; +import * as Globals from '@/bootstrap/globals'; +import TableFieldtype from '@/components/fieldtypes/TableFieldtype.vue'; +import { containerContextKey } from '@/components/ui/Publish/Container.vue'; +import { Input, Button } from '@/components/ui'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); + +test('value cells get content direction while chrome does not', () => { + document.documentElement.setAttribute('dir', 'ltr'); + + const wrapper = mount(TableFieldtype, { + props: { + value: [{ cells: ['hello', 'world'] }], + handle: 'table', + }, + components: { + 'ui-input': Input, + 'ui-button': Button, + }, + global: { + provide: { + [containerContextKey]: { + direction: computed(() => 'rtl'), + }, + }, + }, + }); + + const input = wrapper.find('input'); + expect(input.attributes('dir')).toBe('rtl'); + + const deleteColumnButton = wrapper.find('[aria-label="Delete Column"]'); + expect(deleteColumnButton.attributes('dir')).toBeUndefined(); +}); diff --git a/resources/js/tests/ui-direction.test.js b/resources/js/tests/ui-direction.test.js new file mode 100644 index 00000000000..20da618acb5 --- /dev/null +++ b/resources/js/tests/ui-direction.test.js @@ -0,0 +1,24 @@ +import { test, expect, beforeEach, vi } from 'vitest'; + +let useUiDirection; + +beforeEach(async () => { + vi.resetModules(); + document.documentElement.removeAttribute('dir'); + useUiDirection = (await import('../composables/ui-direction.js')).useUiDirection; +}); + +test('defaults to ltr when html dir is missing', () => { + const { direction } = useUiDirection(); + + expect(direction.value).toBe('ltr'); +}); + +test('updates reactively when the html dir attribute changes', async () => { + const { direction } = useUiDirection(); + expect(direction.value).toBe('ltr'); + + document.documentElement.setAttribute('dir', 'rtl'); + + await vi.waitFor(() => expect(direction.value).toBe('rtl')); +}); From 6a2a69bcf734a50dca9972eeedc03f6af83839ae Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 16 Jul 2026 11:35:06 -0400 Subject: [PATCH 07/20] Make Tabs primitive follow UI direction by default 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. --- resources/js/components/ui/Tabs/Tabs.vue | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/resources/js/components/ui/Tabs/Tabs.vue b/resources/js/components/ui/Tabs/Tabs.vue index 447ba1998cd..91d947726a7 100644 --- a/resources/js/components/ui/Tabs/Tabs.vue +++ b/resources/js/components/ui/Tabs/Tabs.vue @@ -1,18 +1,26 @@ From 2e51342ea52d43b97fff12f2f5ce739aa37e6a3e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 16 Jul 2026 11:37:57 -0400 Subject: [PATCH 08/20] Fix field label/instructions inheriting content direction inside sets 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. --- resources/js/components/ui/Field.vue | 4 +++- resources/js/components/ui/Publish/Field.vue | 3 ++- resources/js/tests/components/Field.test.js | 24 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 resources/js/tests/components/Field.test.js diff --git a/resources/js/components/ui/Field.vue b/resources/js/components/ui/Field.vue index f255d6be449..0b4b9b4db5c 100644 --- a/resources/js/components/ui/Field.vue +++ b/resources/js/components/ui/Field.vue @@ -16,6 +16,8 @@ const props = defineProps({ inline: { type: Boolean, default: false }, /** Badge text to display next to the label. */ badge: { type: String, default: '' }, + /** The reading direction of the field's label, instructions, and errors. */ + dir: { type: String, default: null }, disabled: { type: Boolean, default: false }, /** Error message to display below the field. */ error: { type: String }, @@ -80,7 +82,7 @@ const hasErrors = computed(() => {