diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js
index a84b8d0e411..3f5150061c9 100644
--- a/packages/cms/src/ui.js
+++ b/packages/cms/src/ui.js
@@ -17,6 +17,9 @@ export const {
Combobox,
CommandPaletteItem,
ConfirmationModal,
+ ContentDirection,
+ useContentDirection,
+ useUiDirection,
Context,
ContextFooter,
ContextHeader,
diff --git a/resources/css/vendors/codemirror.css b/resources/css/vendors/codemirror.css
index 3f742a9fbcd..7c1d0799aec 100644
--- a/resources/css/vendors/codemirror.css
+++ b/resources/css/vendors/codemirror.css
@@ -3,6 +3,7 @@
.CodeMirror {
/* Set height, width, borders, and global font properties here */
@apply text-gray-900 dark:text-gray-300 leading-normal min-h-20;
+ direction: ltr;
}
/* CODEMIRROR / PADDING
diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js
index 2b8a3e7cd1e..68653dd3746 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,
@@ -124,4 +125,6 @@ export {
Widget,
registerIconSet,
registerIconSetFromStrings,
+ useContentDirection,
+ useUiDirection,
} from '../../components/ui/index';
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,6 @@ export default {
mode: 'gfm',
dragDrop: false,
keyMap: 'sublime',
- direction: document.querySelector('html').getAttribute('dir') ?? 'ltr',
lineWrapping: true,
viewportMargin: Infinity,
tabindex: 0,
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(() => {
-
+
-import createContext from '@/util/createContext.js';
+import { injectContainerContext, provideContainerContext, containerContextKey } from './context.js';
-export const [injectContainerContext, provideContainerContext, containerContextKey] = createContext('PublishContainer');
+export { injectContainerContext, provideContainerContext, containerContextKey };
+
+
+
+
+
+
diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue
index 0f428d496d8..7e46c3a8657 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);
@@ -238,6 +241,7 @@ const fieldtypeComponentEvents = computed(() => ({
v-show="shouldShowField"
:class="`${config.type}-fieldtype`"
:id="fieldId"
+ :dir="direction"
:instructions="config.instructions"
:instructions-below="config.instructions_position === 'below'"
:required="isRequired"
@@ -277,7 +281,7 @@ const fieldtypeComponentEvents = computed(() => ({
Component does not exist.
-
+
+import { computed } from 'vue';
import { TabsRoot } from 'reka-ui';
+import { useUiDirection } from '@/composables/ui-direction';
const emit = defineEmits(['update:modelValue']);
-defineProps({
+const props = defineProps({
/** The controlled value of the tabs */
modelValue: { type: String, default: null },
/** When `true`, the element will be unmounted on closed state */
unmountOnHide: { type: Boolean, default: true },
+ /** The reading direction of the tabs */
+ dir: { type: String, default: null },
});
+
+const { direction } = useUiDirection();
+
+const dir = computed(() => props.dir ?? direction.value);
-
+
diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js
index d19fd06b96a..9527d5461c8 100644
--- a/resources/js/components/ui/index.js
+++ b/resources/js/components/ui/index.js
@@ -14,6 +14,9 @@ export { default as CheckboxGroup } from './Checkbox/Group.vue';
export { default as CodeEditor } from './CodeEditor.vue';
export { default as Combobox } from './Combobox/Combobox.vue';
export { default as ConfirmationModal } from './Modal/ConfirmationModal.vue';
+export { default as ContentDirection } from './Publish/ContentDirection.vue';
+export { useContentDirection } from '../../composables/content-direction.js';
+export { useUiDirection } from '../../composables/ui-direction.js';
export { default as Context } from './Context/Context.vue';
export { default as ContextFooter } from './Context/Footer.vue';
export { default as ContextHeader } from './Context/Header.vue';
diff --git a/resources/js/composables/content-direction.js b/resources/js/composables/content-direction.js
new file mode 100644
index 00000000000..e031030c842
--- /dev/null
+++ b/resources/js/composables/content-direction.js
@@ -0,0 +1,12 @@
+import { computed, unref } from 'vue';
+import { injectContainerContext } from '@/components/ui/Publish/context.js';
+import { useUiDirection } from './ui-direction';
+
+export function useContentDirection() {
+ const container = injectContainerContext();
+ const { direction: uiDirection } = useUiDirection();
+
+ return {
+ direction: computed(() => (container ? unref(container.direction) : uiDirection.value)),
+ };
+}
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),
+ };
+}
diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js
index 4aa0bbc5e71..4da3ead2380 100644
--- a/resources/js/tests/Package.test.js
+++ b/resources/js/tests/Package.test.js
@@ -138,6 +138,9 @@ it('exports ui', async () => {
'CodeEditor',
'Combobox',
'ConfirmationModal',
+ 'ContentDirection',
+ 'useContentDirection',
+ 'useUiDirection',
'Context',
'ContextFooter',
'ContextHeader',
diff --git a/resources/js/tests/components/Container.test.js b/resources/js/tests/components/Container.test.js
new file mode 100644
index 00000000000..97902d64bbe
--- /dev/null
+++ b/resources/js/tests/components/Container.test.js
@@ -0,0 +1,65 @@
+import { mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test } from 'vitest';
+import { defineComponent, h } from 'vue';
+import * as Globals from '@/bootstrap/globals';
+import Container from '@/components/ui/Publish/Container.vue';
+import { useContentDirection } from '@/composables/content-direction';
+
+Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn]));
+window.__ = (key) => key;
+
+window.Statamic = {
+ $config: {
+ get: (key) => (key === 'sites' ? [{ handle: 'default', direction: 'ltr' }] : undefined),
+ },
+ $dirty: { has: () => false, add: () => {}, remove: () => {} },
+ $events: { $emit: () => {} },
+};
+
+const Probe = defineComponent({
+ setup() {
+ const { direction } = useContentDirection();
+ return { direction };
+ },
+ render() {
+ return h('div', { 'data-direction': this.direction });
+ },
+});
+
+beforeEach(async () => {
+ document.documentElement.setAttribute('dir', 'rtl');
+ // Let the ui-direction composable's MutationObserver pick up the attribute change.
+ await new Promise((resolve) => queueMicrotask(resolve));
+});
+
+afterEach(() => {
+ document.documentElement.removeAttribute('dir');
+});
+
+test('content direction is unaffected by the CP/document direction when the site has an explicit direction', () => {
+ const wrapper = mount(Container, {
+ props: {
+ blueprint: { tabs: [] },
+ site: 'default',
+ },
+ slots: {
+ default: () => h(Probe),
+ },
+ });
+
+ expect(wrapper.find('[data-direction]').attributes('data-direction')).toBe('ltr');
+});
+
+test('content direction falls back to the UI direction when the site cannot be resolved', () => {
+ const wrapper = mount(Container, {
+ props: {
+ blueprint: { tabs: [] },
+ site: 'unknown-site',
+ },
+ slots: {
+ default: () => h(Probe),
+ },
+ });
+
+ expect(wrapper.find('[data-direction]').attributes('data-direction')).toBe('rtl');
+});
diff --git a/resources/js/tests/components/ContentDirection.test.js b/resources/js/tests/components/ContentDirection.test.js
new file mode 100644
index 00000000000..b6149dba3ce
--- /dev/null
+++ b/resources/js/tests/components/ContentDirection.test.js
@@ -0,0 +1,43 @@
+import { mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test } from 'vitest';
+import ContentDirection from '@/components/ui/Publish/ContentDirection.vue';
+
+beforeEach(async () => {
+ document.documentElement.setAttribute('dir', 'rtl');
+ // Let the ui-direction composable's MutationObserver pick up the attribute change.
+ await new Promise((resolve) => queueMicrotask(resolve));
+});
+
+afterEach(() => {
+ document.documentElement.removeAttribute('dir');
+});
+
+test('it renders a div with the content direction by default', () => {
+ const wrapper = mount(ContentDirection, {
+ slots: { default: 'hello' },
+ });
+
+ expect(wrapper.element.tagName).toBe('DIV');
+ expect(wrapper.attributes('dir')).toBe('rtl');
+});
+
+test('it renders the given "as" element', () => {
+ const wrapper = mount(ContentDirection, {
+ props: { as: 'span' },
+ slots: { default: 'hello' },
+ });
+
+ expect(wrapper.element.tagName).toBe('SPAN');
+ expect(wrapper.attributes('dir')).toBe('rtl');
+});
+
+test('asChild merges the dir attribute onto the slot child instead of wrapping it', () => {
+ const wrapper = mount(ContentDirection, {
+ props: { asChild: true },
+ slots: { default: 'hello' },
+ });
+
+ expect(wrapper.element.tagName).toBe('SPAN');
+ expect(wrapper.classes()).toContain('child');
+ expect(wrapper.attributes('dir')).toBe('rtl');
+});
diff --git a/resources/js/tests/components/Field.test.js b/resources/js/tests/components/Field.test.js
new file mode 100644
index 00000000000..d7fd49d0e85
--- /dev/null
+++ b/resources/js/tests/components/Field.test.js
@@ -0,0 +1,24 @@
+import { mount } from '@vue/test-utils';
+import { expect, test } from 'vitest';
+import Field from '@/components/ui/Field.vue';
+
+test('it does not set a dir attribute by default', () => {
+ const wrapper = mount(Field, {
+ props: {
+ label: 'Title',
+ },
+ });
+
+ expect(wrapper.attributes('dir')).toBeUndefined();
+});
+
+test('it sets the dir attribute on the root element when provided', () => {
+ const wrapper = mount(Field, {
+ props: {
+ label: 'Title',
+ dir: 'rtl',
+ },
+ });
+
+ expect(wrapper.attributes('dir')).toBe('rtl');
+});
diff --git a/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js b/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js
new file mode 100644
index 00000000000..ffb0f6a3d2b
--- /dev/null
+++ b/resources/js/tests/components/fieldtypes/MarkdownFieldtype.test.js
@@ -0,0 +1,61 @@
+import { mount } from '@vue/test-utils';
+import { expect, test } from 'vitest';
+import { computed, ref } from 'vue';
+import * as Globals from '@/bootstrap/globals';
+import MarkdownFieldtype from '@/components/fieldtypes/markdown/MarkdownFieldtype.vue';
+import { containerContextKey } from '@/components/ui/Publish/Container.vue';
+
+Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn]));
+window.cp_url = (url) => url;
+window.__ = (key) => key;
+
+function mountField(direction) {
+ return mount(MarkdownFieldtype, {
+ props: {
+ value: 'hello world',
+ handle: 'markdown',
+ config: { buttons: [] },
+ },
+ global: {
+ stubs: {
+ 'publish-field-fullscreen-header': true,
+ 'ui-icon': true,
+ },
+ mocks: {
+ $events: { $on: () => {}, $off: () => {} },
+ },
+ provide: {
+ [containerContextKey]: { direction },
+ },
+ },
+ });
+}
+
+test('chrome does not get an explicit content direction', async () => {
+ document.documentElement.setAttribute('dir', 'ltr');
+
+ const wrapper = mountField(computed(() => 'rtl'));
+ await wrapper.vm.$nextTick();
+
+ const cheatsheetButton = wrapper.find('[aria-label="Show Markdown Cheatsheet"]');
+ expect(cheatsheetButton.attributes('dir')).toBeUndefined();
+});
+
+// The editor is source/code-adjacent and is intentionally always ltr,
+// regardless of content direction (see PR #10931 and its revert #10992).
+test('the CodeMirror editor stays ltr regardless of content direction', async () => {
+ const wrapper = mountField(ref('rtl'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.codemirror.getOption('direction')).toBe('ltr');
+ expect(wrapper.vm.codemirror.getWrapperElement().getAttribute('dir')).toBeNull();
+});
+
+test('the preview pane gets the content direction', async () => {
+ const wrapper = mountField(ref('rtl'));
+ await wrapper.vm.$nextTick();
+
+ // The preview pane is always in the DOM (toggled via v-show), so its
+ // dir attribute can be asserted without switching into preview mode.
+ expect(wrapper.find('.markdown-preview').attributes('dir')).toBe('rtl');
+});
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'));
+});
|