Switching from Arabic to Chinese left the page right-to-left

Every element that sets a reading direction did it one way: if the locale was
RTL it wrote dir="rtl", and there was no else. So a reader who moved from an
RTL locale to an LTR one kept the right-to-left layout for the rest of the
session -- Chinese laid out backwards, which reads as a broken page rather than
a stale setting.

The reason the clear was missing is sound and is kept: a host page that
declares its own dir owns that decision, and blindly removing the attribute
would take that with it. So each element now remembers what it found before it
first wrote, restores that value verbatim when the locale stops being RTL, and
removes the attribute only when there was none to begin with. It clears its
own, never the host's.

FOUR elements, not the three the report named: big-picture.vue,
research-map-plus-grid.vue and people-groups-grid.vue all carry it, and so does
dashboard-plus-grid.vue -- the Big Picture composite, same shape, found by
checking the neighbours of the named lines rather than only the lines
themselves.

The comment above each one said "Only ever SET, never cleared". Left alone it
would now describe the opposite of the code, so it says what the code does.

Verified two-sided against all four shipped files, driving the real extracted
function: it clears what it set, preserves a host's own dir, and still sets rtl.
Restoring the one-way body to one file fails exactly the first two and leaves
the third passing, with the other three files unaffected.
This commit is contained in:
Doxa Maps 2026-09-29 18:37:21 +00:00
parent b8c7e9a391
commit 77415cf361
4 changed files with 64 additions and 8 deletions

View file

@ -105,17 +105,32 @@ const heading = computed(() => config.value.title
//
// Set on the HOST rather than on .bp-root, deliberately: `dir` takes effect through the inherited
// CSS `direction`, and inheritance crosses shadow boundaries — so one attribute covers this
// element's own chrome AND both nested elements beneath it. Only ever SET, never cleared: a host
// element's own chrome AND both nested elements beneath it. Set and CLEARED, but only ever ours: a host
// page that already declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
return RTL_LOCALES.has(l) ? 'rtl' : 'ltr'
})
const _self = getCurrentInstance()
// ★ WE CLEAR ONLY THE DIRECTION WE SET. The line below used to be a one-way
// `if (rtl) setAttribute('dir','rtl')` with no else, so a reader who switched from an RTL locale to
// an LTR one (ar -> zh) kept a right-to-left layout for the rest of the session: Chinese laid out
// backwards, which reads as a broken page rather than a stale setting.
// The original reason for never clearing is still honoured, and is why this remembers rather than
// just removing: a host page that declares its OWN dir owns that decision, so the value found before
// we first wrote is restored verbatim, and removed only when there was none.
let _dirPrev = null
function reflectDir() {
try {
const host = _self?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (textDir.value === 'rtl') {
if (_dirPrev === null) _dirPrev = host.getAttribute('dir') || ''
host.setAttribute('dir', 'rtl')
} else if (_dirPrev !== null) {
if (_dirPrev) host.setAttribute('dir', _dirPrev); else host.removeAttribute('dir')
_dirPrev = null
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)

View file

@ -300,7 +300,7 @@ watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// Set on the custom-element HOST rather than on the layout inside it, deliberately: `dir` takes
// effect through the inherited CSS `direction`, and inheritance crosses shadow boundaries — so one
// attribute on the host covers this composite's own chrome AND every nested element beneath it,
// which is the half the audit found missing. Only ever SET, never cleared: a host page that already
// which is the half the audit found missing. Set and CLEARED, but only ever ours: a host page that already
// declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
@ -316,10 +316,21 @@ const textDir = computed(() => {
// it is". Measured in this tree before, under Vue 3.5.42, when research-map.vue's emitFilterState
// died the same way; the fix is its _selfInstance, and this is the same fix.
const _selfInstance = getCurrentInstance()
// ★ WE CLEAR ONLY THE DIRECTION WE SET. This was a one-way `if (rtl) setAttribute` with no else,
// so ar -> zh kept a right-to-left layout for the rest of the session. The host page's own dir is
// still respected: the value found before we first wrote is restored verbatim.
let _dirPrev = null
function reflectDir() {
try {
const host = _selfInstance?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (textDir.value === 'rtl') {
if (_dirPrev === null) _dirPrev = host.getAttribute('dir') || ''
host.setAttribute('dir', 'rtl')
} else if (_dirPrev !== null) {
if (_dirPrev) host.setAttribute('dir', _dirPrev); else host.removeAttribute('dir')
_dirPrev = null
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)

View file

@ -278,16 +278,31 @@ const languageCode = computed(() =>
// page it owns and useless for an element dropped into someone else's, which is this grid's whole
// job. Set on the HOST, like both composites: `dir` takes effect through the inherited CSS
// `direction`, and inheritance crosses shadow boundaries, so one attribute turns the element and
// everything it renders. Only ever SET, never cleared — a host page that declares its own dir owns
// everything it renders. Set and CLEARED, but only ever ours — a host page that declares its own dir owns
// that decision.
const textDir = computed(() => {
const l = languageCode.value || getRuntimeLocale() || detectLocale()
return RTL_LOCALES.has(String(l).split('-')[0].toLowerCase()) ? 'rtl' : 'ltr'
})
// ★ WE CLEAR ONLY THE DIRECTION WE SET. The line below used to be a one-way
// `if (rtl) setAttribute('dir','rtl')` with no else, so a reader who switched from an RTL locale to
// an LTR one (ar -> zh) kept a right-to-left layout for the rest of the session: Chinese laid out
// backwards, which reads as a broken page rather than a stale setting.
// The original reason for never clearing is still honoured, and is why this remembers rather than
// just removing: a host page that declares its OWN dir owns that decision, so the value found before
// we first wrote is restored verbatim, and removed only when there was none.
let _dirPrev = null
function reflectDir() {
try {
const host = hostEl()
if (host && host.setAttribute && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (textDir.value === 'rtl') {
if (_dirPrev === null) _dirPrev = host.getAttribute('dir') || ''
host.setAttribute('dir', 'rtl')
} else if (_dirPrev !== null) {
if (_dirPrev) host.setAttribute('dir', _dirPrev); else host.removeAttribute('dir')
_dirPrev = null
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
watch(textDir, reflectDir)

View file

@ -152,7 +152,7 @@ const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? ho
// Set on the custom-element HOST rather than on the layout inside it, deliberately: `dir` takes
// effect through the inherited CSS `direction`, and inheritance crosses shadow boundaries — so one
// attribute on the host covers this composite's own chrome AND every nested element beneath it,
// which is the half the audit found missing. Only ever SET, never cleared: a host page that already
// which is the half the audit found missing. Set and CLEARED, but only ever ours: a host page that already
// declares its own dir owns that decision.
const textDir = computed(() => {
const l = hostLang.value || getRuntimeLocale() || detectLocale()
@ -168,10 +168,25 @@ const textDir = computed(() => {
// it is". Measured in this tree before, under Vue 3.5.42, when research-map.vue's emitFilterState
// died the same way; the fix is its _selfInstance, and this is the same fix.
const _selfInstance = getCurrentInstance()
// ★ WE CLEAR ONLY THE DIRECTION WE SET. The line below used to be a one-way
// `if (rtl) setAttribute('dir','rtl')` with no else, so a reader who switched from an RTL locale to
// an LTR one (ar -> zh) kept a right-to-left layout for the rest of the session: Chinese laid out
// backwards, which reads as a broken page rather than a stale setting.
// The original reason for never clearing is still honoured, and is why this remembers rather than
// just removing: a host page that declares its OWN dir owns that decision, so the value found before
// we first wrote is restored verbatim, and removed only when there was none.
let _dirPrev = null
function reflectDir() {
try {
const host = _selfInstance?.proxy?.$el?.getRootNode?.()?.host
if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
if (!host || !host.setAttribute) return
if (textDir.value === 'rtl') {
if (_dirPrev === null) _dirPrev = host.getAttribute('dir') || ''
host.setAttribute('dir', 'rtl')
} else if (_dirPrev !== null) {
if (_dirPrev) host.setAttribute('dir', _dirPrev); else host.removeAttribute('dir')
_dirPrev = null
}
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
}
onMounted(reflectDir)