feat(i18n): add Arabic localization and basic RTL support (#14130)

Co-authored-by: Hengfei Yang <hengfei.yang@gmail.com>
This commit is contained in:
Benziza
2026-09-09 05:33:40 +00:00
committed by GitHub
co-authored by Hengfei Yang
parent fafae6a59e
commit 528100dcf7
23 changed files with 17412 additions and 35 deletions
+5 -6
View File
@@ -53,6 +53,7 @@ no API calls, needs no API key, and installs nothing, so it costs a few seconds.
The list lives in `LANGUAGE_NAMES` in `translator.py` — that dict is the single
source of truth for both the locale codes and the language names used in the prompt.
- 🇸🇦 Arabic (ar-SA)
- 🇹🇷 Turkish (tr-TR)
- 🇨🇳 Simplified Chinese (zh-CN)
- 🇹🇼 Traditional Chinese (zh-TW)
@@ -68,9 +69,6 @@ source of truth for both the locale codes and the language names used in the pro
- 🇵🇱 Polish (pl-PL)
- 🇻🇳 Vietnamese (vi-VN)
RTL languages (Arabic, Persian) are deliberately excluded until the web app has
`dir="rtl"` support — see the note in `translator.py`.
## How It Works
1. **Source File**: All translations originate from `web/src/locales/languages/en-US.json`
@@ -339,6 +337,7 @@ scripts/translations/
web/src/locales/languages/
├── en-US.json # Source file (English)
├── ar-SA.json # Arabic translations
├── tr-TR.json # Turkish translations
├── zh-CN.json # Chinese translations
├── fr-FR.json # French translations
@@ -424,7 +423,7 @@ lose it, nothing breaks; the run simply re-bills those strings.
### Two PRs Conflict in the Generated Locale Files
**Symptom:** two open PRs both changed `en-US.json`; the second to merge has conflicts
in the 14 locale files and in `.translation_state.json`.
in the 15 locale files and in `.translation_state.json`.
This is inherent to generating in each PR rather than in one serialised bot PR. Both
branches rewrote the same generated files. Generating at merge time rather than on
@@ -536,7 +535,7 @@ check that PR:
4. **Click "Merge when ready".** Now the workflow:
- Triggers, because `en-US.json` differs from the base branch
- Translates only the **new or modified** keys to all 14 languages
- Translates only the **new or modified** keys to all 15 languages
- Pushes `fr-FR.json`, `es-ES.json`, etc. plus `.translation_state.json` onto your branch
- That one push re-runs your PR's checks; then it queues and merges
@@ -586,7 +585,7 @@ source of truth that keeps subsequent runs incremental.
## Cost Considerations
Translation is billed per token by DeepSeek. The whole `en-US.json` is ~10,700
strings; a full 14-language rebuild is a one-time cost, and day-to-day runs only
strings; a full 15-language rebuild is a one-time cost, and day-to-day runs only
translate the handful of new/changed keys in a given PR.
### Cost Optimization:
+1 -5
View File
@@ -56,6 +56,7 @@ SOURCE_LOCALE = "en-US"
# Human-readable target names used in the translation prompt, keyed by the target
# locale's filename stem (BCP-47 style, matching web/src/locales/languages/*.json).
LANGUAGE_NAMES = {
"ar-SA": "Arabic",
"tr-TR": "Turkish",
"zh-CN": "Simplified Chinese",
"fr-FR": "French",
@@ -70,11 +71,6 @@ LANGUAGE_NAMES = {
"ru-RU": "Russian",
"pl-PL": "Polish",
"vi-VN": "Vietnamese",
# NOTE: RTL languages (ar Arabic, fa Persian) are intentionally NOT listed
# yet — the web app has no dir="rtl" support, and the locale registry's
# strict on-disk/localeFileMap sync would force them to be user-selectable
# (and auto-served to ar/fa browsers) the moment their files are generated.
# Add them here together with RTL layout support and their web wiring.
}
# The term table injected into every prompt. Strings reach the model as isolated
+16 -5
View File
@@ -15,10 +15,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<router-view></router-view>
<OToastProvider />
<ConfirmDialogProvider />
<CreateAlertDialogProvider />
<ConfigProvider :dir="layoutDirection">
<router-view></router-view>
<OToastProvider />
<ConfirmDialogProvider />
<CreateAlertDialogProvider />
</ConfigProvider>
</template>
<script lang="ts">
@@ -26,16 +28,24 @@ import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { onMounted, watch } from "vue";
import { applyCurrentTheme } from "@/utils/themeManager";
import { getLocale, isRtlLocale } from "@/locales";
import { ConfigProvider } from "reka-ui";
import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue";
import ConfirmDialogProvider from "@/components/ConfirmDialogProvider.vue";
import CreateAlertDialogProvider from "@/components/alerts/CreateAlertDialogProvider.vue";
export default {
components: { OToastProvider, ConfirmDialogProvider, CreateAlertDialogProvider },
components: {
ConfigProvider,
OToastProvider,
ConfirmDialogProvider,
CreateAlertDialogProvider,
},
setup() {
const store = useStore();
const router = useRouter();
const creds = localStorage.getItem("creds");
const layoutDirection: "rtl" | "ltr" = isRtlLocale(getLocale()) ? "rtl" : "ltr";
if (creds) {
router.push("/logs");
}
@@ -74,6 +84,7 @@ export default {
return {
store,
layoutDirection,
};
},
};
+1 -1
View File
@@ -15,7 +15,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="relative flex h-full w-full flex-col" v-bind="$attrs">
<div class="relative flex h-full w-full flex-col" dir="ltr" v-bind="$attrs">
<div
data-test="query-editor"
class="logs-query-editor bg-card-glass-bg min-h-0 flex-1"
+22
View File
@@ -873,6 +873,28 @@ describe("Header Component", () => {
expect(wrapper.props("langList")[0].code).toBe("en-us");
});
it("positions the language submenu with logical RTL-safe utilities", async () => {
const languageWrapper = createWrapper({
stubsOverrides: {
ODropdown: {
template: '<div><slot name="trigger" /><slot /></div>',
},
},
});
languageWrapper.vm.showLanguageSubmenu = true;
await languageWrapper.vm.$nextTick();
const submenu = languageWrapper.find('[data-test="language-dropdown-item"]');
expect(submenu.exists()).toBe(true);
expect(submenu.classes()).toContain("end-full");
expect(submenu.classes()).toContain("me-1");
expect(submenu.classes()).not.toContain("right-full");
expect(submenu.find("button").classes()).toContain("text-start");
languageWrapper.unmount();
});
it("should have access to current language from cookies", () => {
// Verify that getLanguage can be called (it's mocked to return "en-us")
const currentLang = cookies.getLanguage();
+2 -2
View File
@@ -361,7 +361,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Submenu — absolutely positioned to the left of parent dropdown -->
<div
v-if="showLanguageSubmenu"
class="rounded-default bg-dropdown-bg border-dropdown-border absolute top-0 right-full z-9999 me-1 min-w-50 border py-1 shadow-lg dark:shadow-lg"
class="rounded-default bg-dropdown-bg border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border py-1 shadow-lg dark:shadow-lg"
data-test="language-dropdown-item"
@click.stop
>
@@ -370,7 +370,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="lang.code"
type="button"
:data-test="`language-dropdown-item-${lang.code}`"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-1.5 text-left text-sm leading-[1.2] text-inherit"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-1.5 text-start text-sm leading-[1.2] text-inherit"
:class="[
'hover:bg-dropdown-item-hover-bg',
{ 'font-semibold': selectedLanguage.code === lang.code },
+2 -1
View File
@@ -725,6 +725,7 @@ describe("MainLayout Methods and Functions", () => {
it("should have correct language list", () => {
const langList = [
{ code: "en-us", label: "English" },
{ code: "ar", label: "العربية" },
{ code: "tr-turk", label: "Türkçe" },
{ code: "zh-cn", label: "简体中文" },
{ code: "zh-tw", label: "繁體中文" },
@@ -741,7 +742,7 @@ describe("MainLayout Methods and Functions", () => {
{ code: "vi", label: "Tiếng Việt" },
];
expect(langList).toHaveLength(15);
expect(langList).toHaveLength(16);
expect(langList[0].code).toBe("en-us");
expect(langList[0].label).toBe("English");
});
+4
View File
@@ -492,6 +492,10 @@ export default defineComponent({
label: raw("English"),
code: "en-us",
},
{
label: raw("العربية"),
code: "ar",
},
{
label: raw("Türkçe"),
code: "tr-turk",
+10
View File
@@ -53,4 +53,14 @@ describe("OIcon", () => {
// verify the wrapper renders something inside the span.
expect(wrapper.find("span").element.children.length).toBeGreaterThan(0);
});
it("marks directional icons for RTL mirroring", () => {
const wrapper = mount(OIcon, { props: { name: "chevron-left" } });
expect(wrapper.find("span").attributes("data-rtl-mirror")).toBe("true");
});
it("does not mark non-directional icons for RTL mirroring", () => {
const wrapper = mount(OIcon, { props: { name: "settings" } });
expect(wrapper.find("span").attributes("data-rtl-mirror")).toBeUndefined();
});
});
+33
View File
@@ -17,6 +17,38 @@ const imgSrc = computed<string>(() => (props.name?.startsWith("img:") ? props.na
const iconComponent = computed(() => iconRegistry[props.name as keyof typeof iconRegistry]);
const RTL_MIRRORED_ICONS = new Set([
"arrow-back",
"arrow-back-ios",
"arrow-back-ios-new",
"arrow-forward",
"arrow-forward-ios",
"arrow-right",
"arrow-right-alt",
"chevron-left",
"chevron-right",
"exit-to-app",
"first-page",
"fork-right",
"git-branch",
"keyboard-arrow-right",
"keyboard-double-arrow-left",
"keyboard-double-arrow-right",
"last-page",
"left-panel-close",
"left-panel-open",
"login",
"logout",
"navigate-before",
"navigate-next",
"send",
"undo",
]);
const isRtlMirroredIcon = computed<boolean>(() =>
Boolean(props.name && RTL_MIRRORED_ICONS.has(props.name)),
);
// Maps semantic size tokens to Tailwind size utilities (width + height).
// md = 24px matches the default OIcon rendering size.
const sizeClasses: Record<NonNullable<IconProps["size"]>, string> = {
@@ -55,6 +87,7 @@ const isDestructiveIcon = computed<boolean>(() =>
: '',
]"
:data-destructive-icon="isDestructiveIcon ? 'true' : undefined"
:data-rtl-mirror="isRtlMirroredIcon ? 'true' : undefined"
v-bind="label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': 'true' }"
>
<!-- img: prefix → external image -->
+45
View File
@@ -114,6 +114,51 @@ describe("ONavGroup", () => {
expect(flyout().exists()).toBe(true);
});
it("positions the flyout after the rail in LTR", async () => {
document.documentElement.dir = "ltr";
wrapper = mountGroup();
vi.spyOn(wrapper.element, "getBoundingClientRect").mockReturnValue({
left: 10,
right: 98,
top: 40,
bottom: 100,
width: 88,
height: 60,
x: 10,
y: 40,
toJSON: () => ({}),
});
await hoverOpen();
expect(flyout().attributes("style")).toContain("left: 102px");
expect(flyout().attributes("style")).not.toContain("right:");
});
it("positions the flyout before the right-hand rail in RTL", async () => {
const viewportWidth = 1024;
vi.spyOn(document.documentElement, "clientWidth", "get").mockReturnValue(viewportWidth);
document.documentElement.dir = "rtl";
wrapper = mountGroup();
vi.spyOn(wrapper.element, "getBoundingClientRect").mockReturnValue({
left: viewportWidth - 88,
right: viewportWidth,
top: 40,
bottom: 100,
width: 88,
height: 60,
x: viewportWidth - 88,
y: 40,
toJSON: () => ({}),
});
await hoverOpen();
expect(flyout().attributes("style")).toContain("right: 92px");
expect(flyout().attributes("style")).not.toContain("left:");
document.documentElement.dir = "ltr";
});
// The Infra tile holds ONE child (Database Monitoring) behind the
// `databaseMonitoring` runtime gate. Rendering the tile regardless of its
// children would leave a dead "Infra" entry on every build with the feature
+5 -2
View File
@@ -293,10 +293,13 @@ async function positionFlyout() {
const rect = wrapper.getBoundingClientRect();
// Small breathing gap between the rail and the flyout so they don't touch.
const GAP = 4;
const left = rect.right + GAP;
const isRtl = document.documentElement.dir === "rtl";
const horizontalPosition: Record<string, string> = isRtl
? { right: `${document.documentElement.clientWidth - rect.left + GAP}px` }
: { left: `${rect.right + GAP}px` };
flyoutStyle.value = {
position: "fixed",
left: `${left}px`,
...horizontalPosition,
top: `${rect.top}px`,
zIndex: "6000",
};
+24
View File
@@ -63,6 +63,30 @@ describe("OSwitch", () => {
expect(wrapper.emitted("update:modelValue")![0][0]).toBe(true);
});
it("mirrors the checked-thumb offset for RTL at every size", () => {
for (const [size, offset] of [
["sm", "2"],
["md", "2.5"],
["lg", "3"],
["xl", "5"],
] as const) {
const sized = mount(OSwitch, { props: { size, modelValue: true } });
const thumb = sized.find('[role="switch"]').find("span");
expect(thumb.classes()).toContain(`translate-x-${offset}`);
expect(thumb.classes()).toContain(`rtl:-translate-x-${offset}`);
sized.unmount();
}
});
it("parks the thumb at the reading edge while unchecked", () => {
wrapper = mount(OSwitch, { props: { modelValue: false } });
const thumb = wrapper.find('[role="switch"]').find("span");
expect(thumb.classes()).toContain("translate-x-0");
expect(thumb.classes().some((className) => className.startsWith("rtl:"))).toBe(false);
});
it("renders correctly with lg size and still toggles", async () => {
wrapper = mount(OSwitch, { props: { size: "lg", modelValue: false } });
expect(wrapper.find("button").exists()).toBe(true);
+9 -5
View File
@@ -59,28 +59,32 @@ function toggle() {
// ── Track sizes ────────────────────────────────────────────────────────────
// border-2 (2px each side) + p-0.5 (2px each side) = 4px eaten from each dimension
// thumb = track_height - 4(border) - 4(padding), translate = track_width - 4 - 4 - thumb
type TrackSize = { track: string; thumb: string; thumbTranslate: string };
type TrackSize = {
track: string;
thumb: string;
thumbTranslate: string;
};
const trackSizes: Record<NonNullable<SwitchProps["size"]>, TrackSize> = {
sm: {
track: "w-6 h-3.5",
thumb: "size-1.5",
thumbTranslate: "translate-x-2",
thumbTranslate: "translate-x-2 rtl:-translate-x-2",
},
md: {
track: "w-7 h-4.5",
thumb: "size-2.5",
thumbTranslate: "translate-x-2.5",
thumbTranslate: "translate-x-2.5 rtl:-translate-x-2.5",
},
lg: {
track: "w-8 h-5",
thumb: "size-3",
thumbTranslate: "translate-x-3",
thumbTranslate: "translate-x-3 rtl:-translate-x-3",
},
xl: {
track: "w-11.5 h-6",
thumb: "size-4.5",
thumbTranslate: "translate-x-5",
thumbTranslate: "translate-x-5 rtl:-translate-x-5",
},
};
@@ -109,6 +109,7 @@ describe("alert_library locale parity", () => {
// "yükle" interchangeably — and must exclude "kuruluş", which means
// organization, not installation.
const INSTALL_STEMS: Record<string, RegExp[]> = {
"ar-SA": [/تثبيت/, /مثبت/, /تركيب/],
"de-DE": [/instal/i],
"es-ES": [/instal/i, /instál/i],
"fr-FR": [/instal/i],
@@ -15,7 +15,7 @@
// The sensitivity control's copy is half the defect: the old tooltip described
// the opposite of what the detector does. `no-missing-keys` reads en-US alone,
// so nothing else would notice the other 14 locales keeping the wrong sentence.
// so nothing else would notice the other locales keeping the wrong sentence.
//
// Placeholder parity is NOT checked here — localeMessages.spec.ts already does
// it compiler-accurately, and allows the legitimate subset case this file's
@@ -77,8 +77,10 @@ const KEPT = ["alerts.sensitivity", "alerts.anomaly.sensitivityTooltip"];
// The backwards copy, frozen per locale. Asserting each locale MOVED off its own
// stale sentence is the only mechanical way to catch a translation pass that
// updated English and left the other 14 describing behaviour that never existed.
// updated English and left the other locales describing behaviour that never existed.
const STALE_TOOLTIPS: Record<string, string> = {
"ar-SA":
"اضبط نطاق نقاط الشذوذ للتحكم في الحساسية. النقاط التي تكون خارج هذا النطاق لن تفعل التنبيهات. استخدم المخطط لعرض البيانات التاريخية وضبط المؤشر وفقا لذلك.",
"de-DE":
"Passen Sie den Bereich für die Anomaliebewertung an, um die Empfindlichkeit zu kontrollieren. Punkte, deren Werte außerhalb dieses Bereichs liegen, lösen keine Warnmeldungen aus. Verwenden Sie das Diagramm, um historische Daten zu visualisieren und entsprechend anzupassen.",
"en-US":
@@ -115,7 +117,7 @@ describe("anomaly sensitivity locale parity", () => {
it("reads every locale file", () => {
// An exact count: a glob that silently matched fewer would make every
// per-locale assertion below vacuous.
expect(ALL_LOCALES.map(([name]) => name)).toHaveLength(15);
expect(ALL_LOCALES.map(([name]) => name)).toHaveLength(16);
});
it("knows the stale tooltip for every locale it will check", () => {
+28 -1
View File
@@ -25,7 +25,7 @@ vi.mock("@/utils/cookies", () => ({
}));
import { getNumberLocale, APP_LOCALE_TO_BCP47 } from "@/locales/numberFormat";
import { getLocale, localeFileMap } from "@/locales";
import { applyDocumentLocale, getLocale, isRtlLocale, localeFileMap } from "@/locales";
const withNavigatorLanguage = (language: string, assertion: () => void) => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, "language");
@@ -76,6 +76,7 @@ describe("getNumberLocale (locale format unit)", () => {
it("maps app language codes to valid BCP-47 tags", () => {
const cases: Array<[string, string]> = [
["en-us", "en-US"],
["ar", "ar-SA-u-nu-latn"],
["tr-turk", "tr-TR"],
["zh-cn", "zh-CN"],
["zh-tw", "zh-TW"],
@@ -114,6 +115,13 @@ describe("getNumberLocale (locale format unit)", () => {
}).format(1234567.89);
expect(en).toBe("1,234,567.89");
});
it("keeps Western digits for the Arabic UI", () => {
(getLanguage as any).mockReturnValue("ar");
const ar = new Intl.NumberFormat(getNumberLocale(), { useGrouping: false }).format(1234567890);
expect(ar).toBe("1234567890");
});
});
describe("navigator language detection", () => {
@@ -126,6 +134,8 @@ describe("navigator language detection", () => {
["de-DE", "de"],
["fr-IT", "fr"],
["zh-TW", "zh-tw"],
["ar-SA", "ar"],
["ar-EG", "ar"],
["en-DE", "en-us"],
["en-IT", "en-us"],
])("resolves %s to %s", (language, expected) => {
@@ -141,3 +151,20 @@ describe("navigator language detection", () => {
withNavigatorLanguage(language, () => expect(getNumberLocale()).toBe(expected));
});
});
describe("document locale", () => {
it("identifies only registered RTL locales", () => {
expect(isRtlLocale("ar")).toBe(true);
expect(isRtlLocale("en-us")).toBe(false);
});
it.each([
["ar", "ar-SA", "rtl"],
["fr", "fr-FR", "ltr"],
["unknown", "en-US", "ltr"],
])("applies %s to the document", (locale, language, direction) => {
applyDocumentLocale(locale);
expect(document.documentElement.lang).toBe(language);
expect(document.documentElement.dir).toBe(direction);
});
});
+12 -1
View File
@@ -19,7 +19,7 @@ import { setBadgeTranslator } from "@/lib/core/Badge/badgeI18n";
// Only the fallback locale (en-us) is bundled into the main chunk. Every other
// language is code-split and loaded on demand via loadLocaleMessages() so we
// don't ship all 15 locale files (~5MB raw) to every user on first load.
// don't ship every locale file to every user on first load.
import enLocale from "./languages/en-US.json";
// Maps an i18n locale code to its source file name (without extension).
@@ -28,6 +28,7 @@ import enLocale from "./languages/en-US.json";
// silently falls back to en-us.
export const localeFileMap: Record<string, string> = {
"en-us": "en-US",
ar: "ar-SA",
"zh-cn": "zh-CN",
"tr-turk": "tr-TR",
de: "de-DE",
@@ -44,6 +45,16 @@ export const localeFileMap: Record<string, string> = {
vi: "vi-VN",
};
const RTL_LOCALES = new Set(["ar"]);
export const isRtlLocale = (locale: string): boolean => RTL_LOCALES.has(locale);
export const applyDocumentLocale = (locale: string): void => {
if (typeof document === "undefined") return;
document.documentElement.lang = localeFileMap[locale] ?? "en-US";
document.documentElement.dir = isRtlLocale(locale) ? "rtl" : "ltr";
};
export const getLocale = () => {
const cookieLanguage = getLanguage();
if (cookieLanguage) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@
// card disappear with nothing but `SyntaxError: 2` in the console.
//
// This ran against en-US only, which left the far likelier source uncovered: the
// other 14 locales are machine-translated, and a translator that localises a
// other locales are machine-translated, and a translator that localises a
// placeholder NAME produces exactly the same crash in exactly the same way.
//
// en-US "Are you sure you want to delete {identifier}?"
+1
View File
@@ -18,6 +18,7 @@ import { getLanguage } from "@/utils/cookies";
// App language codes are not all valid BCP-47, so map them for Intl.NumberFormat.
export const APP_LOCALE_TO_BCP47: Record<string, string> = {
"en-us": "en-US",
ar: "ar-SA-u-nu-latn",
"tr-turk": "tr-TR",
"zh-cn": "zh-CN",
"zh-tw": "zh-TW",
+6 -2
View File
@@ -17,8 +17,9 @@ import { createApp } from "vue";
import store from "./stores";
import App from "./App.vue";
import createRouter from "./router";
import i18n, { getLocale, loadLocaleMessages } from "./locales";
import i18n, { applyDocumentLocale, getLocale, loadLocaleMessages } from "./locales";
import "./styles/tailwind.css";
import "./styles/rtl.css";
// Global generated-content stylesheet: syntax classes (.log-key, .log-string, …)
// applied to v-html-highlighted log output across logs/traces/RUM. Loaded once
// here instead of re-@imported inside each consumer's <style> block.
@@ -39,6 +40,9 @@ import { raw } from "@/types/i18n";
// paint already uses the correct colors (no flash of the base stylesheet theme).
bootstrapTheme();
const activeLocale = getLocale();
applyDocumentLocale(activeLocale);
const app = createApp(App);
const router = createRouter(store);
@@ -282,7 +286,7 @@ router.onError(async (error) => {
// Ensure the active locale's messages are loaded (en-us is bundled; any other
// language is fetched as a code-split chunk) before the first render.
loadLocaleMessages(getLocale())
loadLocaleMessages(activeLocale)
// On a locale-chunk load failure, fall back to the bundled en-us messages
// (no console noise). The app must mount regardless.
.catch(() => {})
+26
View File
@@ -0,0 +1,26 @@
[dir="rtl"] [data-rtl-mirror="true"] > svg {
transform: scaleX(-1);
}
[dir="rtl"] pre,
[dir="rtl"] code,
[dir="rtl"] kbd,
[dir="rtl"] samp,
[dir="rtl"] .monaco-editor {
direction: ltr;
text-align: left;
unicode-bidi: isolate;
}
[dir="rtl"] .o2-echarts-tooltip {
direction: ltr;
text-align: left;
}
[dir="rtl"] input[type="email"]:not(:placeholder-shown),
[dir="rtl"] input[type="url"]:not(:placeholder-shown),
[dir="rtl"] input[type="tel"]:not(:placeholder-shown) {
direction: ltr;
text-align: left;
unicode-bidi: isolate;
}