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
This commit is contained in:
xhzeem
2026-07-21 23:08:02 +02:00
committed by Linus Rath
parent adb8686293
commit 80f76abc38
4 changed files with 73 additions and 23 deletions
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl'; import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store'; import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [showCalendarPicker, setShowCalendarPicker] = 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<HTMLButtonElement>(null); const pickerTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(''); const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown'); const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
@@ -1026,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} }
if (pickerTriggerRef.current) { if (pickerTriggerRef.current) {
const rect = pickerTriggerRef.current.getBoundingClientRect(); 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); setShowCalendarPicker(true);
}} }}
@@ -1041,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
<div <div
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1" className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
style={{ top: pickerPosition.top, left: pickerPosition.left }} style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
> >
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t('select_calendar')} {t('select_calendar')}
+22 -4
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils"; import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -54,18 +55,35 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (!buttonRef.current) return; if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const rtl = isDocumentRTL();
if (variant === "rail") { if (variant === "rail") {
setPopoverStyle({ setPopoverStyle(
rtl
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
: {
position: "fixed", position: "fixed",
left: rect.right + 8, left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom), bottom: Math.max(8, window.innerHeight - rect.bottom),
}); }
);
} else { } else {
setPopoverStyle({ setPopoverStyle(
rtl
? {
position: "fixed",
right: window.innerWidth - rect.right,
top: rect.bottom + 4,
}
: {
position: "fixed", position: "fixed",
left: rect.left, left: rect.left,
top: rect.bottom + 4, top: rect.bottom + 4,
}); }
);
} }
}, [variant]); }, [variant]);
+21 -4
View File
@@ -18,6 +18,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getMaxAccounts } from "@/lib/account-utils"; import { getMaxAccounts } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -70,11 +71,19 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (!buttonRef.current) return; if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
setPopoverStyle({ setPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: window.innerHeight - rect.bottom,
}
: {
position: "fixed", position: "fixed",
left: rect.right + 8, left: rect.right + 8,
bottom: window.innerHeight - rect.bottom, bottom: window.innerHeight - rect.bottom,
}); }
);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -218,11 +227,19 @@ export function NavigationRail({
const updateLogoutPosition = useCallback(() => { const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return; if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect(); const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle({ setLogoutPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
: {
position: "fixed", position: "fixed",
left: rect.right + 8, left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom), bottom: Math.max(8, window.innerHeight - rect.bottom),
}); }
);
}, []); }, []);
useEffect(() => { useEffect(() => {
+10
View File
@@ -4,3 +4,13 @@ const rtlLocales = new Set(['ar', 'he', 'fa']);
export function getLocaleDirection(locale: string): 'ltr' | 'rtl' { export function getLocaleDirection(locale: string): 'ltr' | 'rtl' {
return rtlLocales.has(locale) ? 'rtl' : 'ltr'; 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';
}