mirror of https://github.com/djteang/OrangeTV.git
Fix stale overlay scroll locks
This commit is contained in:
parent
f8eb7cea4c
commit
12d88d3991
File diff suppressed because one or more lines are too long
|
|
@ -25,13 +25,13 @@ import {
|
||||||
import { getDoubanCategories } from '@/lib/douban.client';
|
import { getDoubanCategories } from '@/lib/douban.client';
|
||||||
import { DoubanItem } from '@/lib/types';
|
import { DoubanItem } from '@/lib/types';
|
||||||
|
|
||||||
|
import { AnnouncementBanner } from '@/components/AnnouncementBanner';
|
||||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
import ContinueWatching from '@/components/ContinueWatching';
|
import ContinueWatching from '@/components/ContinueWatching';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
import { AppDialog } from '@/components/ui/HeroPrimitives';
|
|
||||||
|
|
||||||
function HomeClient() {
|
function HomeClient() {
|
||||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||||
|
|
@ -190,6 +190,13 @@ function HomeClient() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mx-auto max-w-[1380px] space-y-10'>
|
<div className='mx-auto max-w-[1380px] space-y-10'>
|
||||||
|
{announcement && showAnnouncement && (
|
||||||
|
<AnnouncementBanner
|
||||||
|
announcement={announcement}
|
||||||
|
onDismiss={() => handleCloseAnnouncement(announcement)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'favorites' ? (
|
{activeTab === 'favorites' ? (
|
||||||
// 收藏夹视图
|
// 收藏夹视图
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -427,25 +434,6 @@ function HomeClient() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{announcement && (
|
|
||||||
<AppDialog
|
|
||||||
isOpen={showAnnouncement}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) handleCloseAnnouncement(announcement);
|
|
||||||
}}
|
|
||||||
title='提示'
|
|
||||||
footer={
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
onPress={() => handleCloseAnnouncement(announcement)}
|
|
||||||
>
|
|
||||||
我知道了
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className='text-sm leading-6 text-muted'>{announcement}</p>
|
|
||||||
</AppDialog>
|
|
||||||
)}
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button, Card } from '@heroui/react';
|
||||||
|
|
||||||
|
interface AnnouncementBannerProps {
|
||||||
|
announcement: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementBanner({
|
||||||
|
announcement,
|
||||||
|
onDismiss,
|
||||||
|
}: AnnouncementBannerProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Header className='flex-row items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<Card.Description>Announcement</Card.Description>
|
||||||
|
<Card.Title>提示</Card.Title>
|
||||||
|
</div>
|
||||||
|
<Button size='sm' variant='secondary' onPress={onDismiss}>
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p className='text-sm leading-6 text-muted'>{announcement}</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -72,29 +72,6 @@ export const UserMenu: React.FC = () => {
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
const [showCropper, setShowCropper] = useState(false);
|
const [showCropper, setShowCropper] = useState(false);
|
||||||
|
|
||||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSettingsOpen || isChangePasswordOpen || isChangeAvatarOpen) {
|
|
||||||
const body = document.body;
|
|
||||||
const html = document.documentElement;
|
|
||||||
|
|
||||||
// 保存原始样式
|
|
||||||
const originalBodyOverflow = body.style.overflow;
|
|
||||||
const originalHtmlOverflow = html.style.overflow;
|
|
||||||
|
|
||||||
// 只设置 overflow 来阻止滚动
|
|
||||||
body.style.overflow = 'hidden';
|
|
||||||
html.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
|
|
||||||
// 恢复所有原始样式
|
|
||||||
body.style.overflow = originalBodyOverflow;
|
|
||||||
html.style.overflow = originalHtmlOverflow;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isSettingsOpen, isChangePasswordOpen]);
|
|
||||||
|
|
||||||
// 设置相关状态
|
// 设置相关状态
|
||||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||||
|
|
|
||||||
|
|
@ -49,28 +49,6 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
const body = document.body;
|
|
||||||
const html = document.documentElement;
|
|
||||||
|
|
||||||
// 保存原始样式
|
|
||||||
const originalBodyOverflow = body.style.overflow;
|
|
||||||
const originalHtmlOverflow = html.style.overflow;
|
|
||||||
|
|
||||||
// 只设置 overflow 来阻止滚动
|
|
||||||
body.style.overflow = 'hidden';
|
|
||||||
html.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// 恢复所有原始样式
|
|
||||||
body.style.overflow = originalBodyOverflow;
|
|
||||||
html.style.overflow = originalHtmlOverflow;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// 获取远程变更日志
|
// 获取远程变更日志
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { AnnouncementBanner } from '../AnnouncementBanner';
|
||||||
|
|
||||||
|
describe('AnnouncementBanner', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a non-blocking announcement without a dialog or scroll lock', () => {
|
||||||
|
const onDismiss = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AnnouncementBanner
|
||||||
|
announcement='请注意站点公告'
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('请注意站点公告')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(document.documentElement.style.overflow).toBe('');
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '我知道了' }));
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,11 +20,41 @@ import type {
|
||||||
SpinnerProps,
|
SpinnerProps,
|
||||||
TabsProps,
|
TabsProps,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef, useEffect } from 'react';
|
||||||
import type { Key, ReactNode } from 'react';
|
import type { Key, ReactNode } from 'react';
|
||||||
|
|
||||||
type AppButtonProps = ButtonProps;
|
type AppButtonProps = ButtonProps;
|
||||||
|
|
||||||
|
let activeRootOverlayCount = 0;
|
||||||
|
|
||||||
|
function releaseRootScrollLockIfIdle() {
|
||||||
|
if (activeRootOverlayCount > 0 || typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (html.style.overflow === 'hidden') {
|
||||||
|
html.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.style.overflow === 'hidden') {
|
||||||
|
body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRootOverlayCleanup(isOpen: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
|
||||||
|
activeRootOverlayCount += 1;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activeRootOverlayCount = Math.max(0, activeRootOverlayCount - 1);
|
||||||
|
releaseRootScrollLockIfIdle();
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
}
|
||||||
|
|
||||||
export const AppButton = forwardRef<HTMLButtonElement, AppButtonProps>(
|
export const AppButton = forwardRef<HTMLButtonElement, AppButtonProps>(
|
||||||
function AppButton(props, ref) {
|
function AppButton(props, ref) {
|
||||||
return <Button ref={ref} {...props} />;
|
return <Button ref={ref} {...props} />;
|
||||||
|
|
@ -151,6 +181,7 @@ export function AppDialog({
|
||||||
isDismissable = true,
|
isDismissable = true,
|
||||||
}: AppDialogProps) {
|
}: AppDialogProps) {
|
||||||
const state = useOverlayState({ isOpen, onOpenChange });
|
const state = useOverlayState({ isOpen, onOpenChange });
|
||||||
|
useRootOverlayCleanup(isOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal state={state}>
|
<Modal state={state}>
|
||||||
|
|
@ -202,6 +233,7 @@ export function AppDrawer({
|
||||||
isDismissable = true,
|
isDismissable = true,
|
||||||
}: AppDrawerProps) {
|
}: AppDrawerProps) {
|
||||||
const state = useOverlayState({ isOpen, onOpenChange });
|
const state = useOverlayState({ isOpen, onOpenChange });
|
||||||
|
useRootOverlayCleanup(isOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer state={state}>
|
<Drawer state={state}>
|
||||||
|
|
|
||||||
|
|
@ -72,4 +72,38 @@ describe('HeroPrimitives', () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: '播放' }));
|
fireEvent.click(screen.getByRole('button', { name: '播放' }));
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale root scroll locks when dialogs unmount', () => {
|
||||||
|
const onOpenChange = jest.fn();
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<AppDialog isOpen title='提示' onOpenChange={onOpenChange}>
|
||||||
|
<p>内容</p>
|
||||||
|
</AppDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.documentElement.style.overflow).toBe('');
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stale root scroll locks when drawers unmount', () => {
|
||||||
|
const onOpenChange = jest.fn();
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<AppDrawer isOpen title='更多操作' onOpenChange={onOpenChange}>
|
||||||
|
<p>内容</p>
|
||||||
|
</AppDrawer>
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.documentElement.style.overflow).toBe('');
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue