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'; +}