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';
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<string | null>(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<HTMLButtonElement>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('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(
<div
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">
{t('select_calendar')}
+28 -10
View File
@@ -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]);
+27 -10
View File
@@ -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(() => {
+10
View File
@@ -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';
}