From 80f76abc38f015313105f80efbc66278412c2485 Mon Sep 17 00:00:00 2001 From: xhzeem Date: Tue, 21 Jul 2026 23:14:11 +0300 Subject: [PATCH] fix(rtl): flip JS-positioned popovers (storage, logout, account switcher, calendar picker) These popovers are portaled and positioned via inline styles computed from getBoundingClientRect() rather than Tailwind classes, so the logical start-0/end-0 fix doesn't reach them. They always anchored to the physical right of their trigger (rect.right + 8), which in RTL pushes them further into the edge the trigger is already flush against instead of toward the visible content area. Added isDocumentRTL() to i18n/direction.ts and used it to mirror the computed position in: - navigation-rail.tsx: storage quota popover, logout/switch-account menu - account-switcher.tsx: both the rail and expanded-sidebar variants - calendar-invitation-banner.tsx: the "add to calendar" picker --- .../email/calendar-invitation-banner.tsx | 11 ++++-- components/layout/account-switcher.tsx | 38 ++++++++++++++----- components/layout/navigation-rail.tsx | 37 +++++++++++++----- i18n/direction.ts | 10 +++++ 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index e3293e2c..9b8b0fe4 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { useTranslations, useFormatter } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; +import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const [actionError, setActionError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [showCalendarPicker, setShowCalendarPicker] = useState(false); - const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null); + const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); const pickerTriggerRef = useRef(null); const [selectedCalendarId, setSelectedCalendarId] = useState(''); const [rawIcsMethod, setRawIcsMethod] = useState('unknown'); @@ -1026,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } if (pickerTriggerRef.current) { const rect = pickerTriggerRef.current.getBoundingClientRect(); - setPickerPosition({ top: rect.bottom + 4, left: rect.left }); + setPickerPosition( + isDocumentRTL() + ? { top: rect.bottom + 4, right: window.innerWidth - rect.right } + : { top: rect.bottom + 4, left: rect.left } + ); } setShowCalendarPicker(true); }} @@ -1041,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
{t('select_calendar')} diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index f8c1d6c6..5c55fbc5 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -7,6 +7,7 @@ import { useTranslations } from "next-intl"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils"; +import { isDocumentRTL } from "@/i18n/direction"; import { cn } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; import { Avatar } from "@/components/ui/avatar"; @@ -54,18 +55,35 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const updatePosition = useCallback(() => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); + const rtl = isDocumentRTL(); if (variant === "rail") { - setPopoverStyle({ - position: "fixed", - left: rect.right + 8, - bottom: Math.max(8, window.innerHeight - rect.bottom), - }); + setPopoverStyle( + rtl + ? { + position: "fixed", + right: window.innerWidth - rect.left + 8, + bottom: Math.max(8, window.innerHeight - rect.bottom), + } + : { + position: "fixed", + left: rect.right + 8, + bottom: Math.max(8, window.innerHeight - rect.bottom), + } + ); } else { - setPopoverStyle({ - position: "fixed", - left: rect.left, - top: rect.bottom + 4, - }); + setPopoverStyle( + rtl + ? { + position: "fixed", + right: window.innerWidth - rect.right, + top: rect.bottom + 4, + } + : { + position: "fixed", + left: rect.left, + top: rect.bottom + 4, + } + ); } }, [variant]); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 5cc3e869..f2bade53 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -18,6 +18,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getMaxAccounts } from "@/lib/account-utils"; +import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -70,11 +71,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to const updatePosition = useCallback(() => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); - setPopoverStyle({ - position: "fixed", - left: rect.right + 8, - bottom: window.innerHeight - rect.bottom, - }); + setPopoverStyle( + isDocumentRTL() + ? { + position: "fixed", + right: window.innerWidth - rect.left + 8, + bottom: window.innerHeight - rect.bottom, + } + : { + position: "fixed", + left: rect.right + 8, + bottom: window.innerHeight - rect.bottom, + } + ); }, []); useEffect(() => { @@ -218,11 +227,19 @@ export function NavigationRail({ const updateLogoutPosition = useCallback(() => { if (!logoutBtnRef.current) return; const rect = logoutBtnRef.current.getBoundingClientRect(); - setLogoutPopoverStyle({ - position: "fixed", - left: rect.right + 8, - bottom: Math.max(8, window.innerHeight - rect.bottom), - }); + setLogoutPopoverStyle( + isDocumentRTL() + ? { + position: "fixed", + right: window.innerWidth - rect.left + 8, + bottom: Math.max(8, window.innerHeight - rect.bottom), + } + : { + position: "fixed", + left: rect.right + 8, + bottom: Math.max(8, window.innerHeight - rect.bottom), + } + ); }, []); useEffect(() => { diff --git a/i18n/direction.ts b/i18n/direction.ts index ce7bcb3f..dadfcac7 100644 --- a/i18n/direction.ts +++ b/i18n/direction.ts @@ -4,3 +4,13 @@ const rtlLocales = new Set(['ar', 'he', 'fa']); export function getLocaleDirection(locale: string): 'ltr' | 'rtl' { return rtlLocales.has(locale) ? 'rtl' : 'ltr'; } + +/** + * Whether the document is currently rendering right-to-left. For popovers + * positioned in JS via getBoundingClientRect() (Tailwind's logical start-0/ + * end-0 utilities don't apply to inline fixed-position styles), check this + * to anchor on the correct physical side. + */ +export function isDocumentRTL(): boolean { + return typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; +}