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:
@@ -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')}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user