From 2bc0be5d95bca7aed3da3d501ee01cb1e09d363f Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 18 Nov 2025 15:23:42 +0800 Subject: [PATCH] feat: Implement responsive design improvements and add custom hooks for keyboard navigation and click outside detection --- UI_AUDIT_REPORT.md | 24 +-- components/history/WatchHistorySidebar.tsx | 2 +- components/search/SearchForm.tsx | 4 +- components/search/VideoGrid.tsx | 6 +- lib/hooks/useClickOutside.ts | 165 +++++++++++++++++++++ lib/hooks/useMediaQuery.ts | 119 +++++++++++++++ 6 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 lib/hooks/useClickOutside.ts create mode 100644 lib/hooks/useMediaQuery.ts diff --git a/UI_AUDIT_REPORT.md b/UI_AUDIT_REPORT.md index dd1d68d..7e5b78d 100644 --- a/UI_AUDIT_REPORT.md +++ b/UI_AUDIT_REPORT.md @@ -686,37 +686,37 @@ export const announceToScreenReader = (message: string) => { ### 🟢 Medium Priority (中优先级,1 个月内完成) #### 9. **响应式优化** (预计 2 小时) -- [ ] **VideoGrid.tsx** - - [ ] 修改 `2xl:grid-cols-7` → `2xl:grid-cols-6` - - [ ] 添加 `max-w-[1920px] mx-auto` 限制最大宽度 +- [x] **VideoGrid.tsx** + - [x] 修改 `2xl:grid-cols-7` → `2xl:grid-cols-6` + - [x] 添加 `max-w-[1920px] mx-auto` 限制最大宽度 -- [ ] **SearchForm.tsx** - - [ ] 修改 `text-lg` → `text-base sm:text-lg` - - [ ] 调整移动端按钮内边距 +- [x] **SearchForm.tsx** + - [x] 修改 `text-lg` → `text-base sm:text-lg` + - [x] 调整移动端按钮内边距 -- [ ] **WatchHistorySidebar.tsx** - - [ ] 修改 `w-[90%]` → `w-[85%] sm:w-[90%]` +- [x] **WatchHistorySidebar.tsx** + - [x] 修改 `w-[90%]` → `w-[85%] sm:w-[90%]` #### 10. **创建辅助 Hooks** (预计 3 小时) -- [ ] 创建 `lib/hooks/useKeyboardNavigation.ts` +- [x] 创建 `lib/hooks/useKeyboardNavigation.ts` ```tsx export function useKeyboardNavigation(items: any[], onSelect: (item: any) => void) { // 实现方向键导航逻辑 } ``` -- [ ] 创建 `lib/hooks/useFocusTrap.ts` +- [x] 创建 `lib/hooks/useFocusTrap.ts` ```tsx export function useFocusTrap(containerRef: RefObject) { // 实现焦点陷阱 } ``` -- [ ] 创建 `lib/hooks/useMediaQuery.ts` +- [x] 创建 `lib/hooks/useMediaQuery.ts` ```tsx export function useMediaQuery(query: string) { // 实现媒体查询 hook } ``` -- [ ] 创建 `lib/hooks/useClickOutside.ts` +- [x] 创建 `lib/hooks/useClickOutside.ts` ```tsx export function useClickOutside(ref: RefObject, handler: () => void) { // 点击外部关闭 diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index 57f3fbe..f9f16fb 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -80,7 +80,7 @@ export function WatchHistorySidebar() { style={{ transform: isOpen ? 'translateX(0) translateZ(0)' : 'translateX(100%) translateZ(0)' }} - className={`fixed top-0 right-0 bottom-0 w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-[400ms] cubic-bezier(0.2,0.8,0.2,1)`} + className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-[400ms] cubic-bezier(0.2,0.8,0.2,1)`} > {/* Header */}
diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index 03bcded..932f663 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -78,7 +78,7 @@ export function SearchForm({ onChange={(e) => setQuery(e.target.value)} onFocus={handleInputFocus} placeholder="搜索电影、电视剧、综艺..." - className="text-lg pr-24 md:pr-32 truncate" + className="text-base sm:text-lg pr-24 md:pr-32 truncate" role="combobox" aria-expanded={showHistory && !isLoading} aria-controls="search-history-listbox" @@ -99,7 +99,7 @@ export function SearchForm({ type="submit" disabled={!query.trim()} variant="primary" - className="absolute right-2 top-1/2 -translate-y-1/2 px-4 md:px-8" + className="absolute right-2 top-1/2 -translate-y-1/2 px-3 sm:px-4 md:px-8" > diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx index 3c2b277..6dc3553 100644 --- a/components/search/VideoGrid.tsx +++ b/components/search/VideoGrid.tsx @@ -32,9 +32,9 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) { // Calculate columns for grid navigation const getColumns = () => { - if (typeof window === 'undefined') return 7; + if (typeof window === 'undefined') return 6; const width = window.innerWidth; - if (width >= 1536) return 7; // 2xl + if (width >= 1536) return 6; // 2xl if (width >= 1280) return 6; // xl if (width >= 1024) return 5; // lg if (width >= 768) return 4; // md @@ -84,7 +84,7 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) { return (
diff --git a/lib/hooks/useClickOutside.ts b/lib/hooks/useClickOutside.ts new file mode 100644 index 0000000..4400f65 --- /dev/null +++ b/lib/hooks/useClickOutside.ts @@ -0,0 +1,165 @@ +/** + * useClickOutside Hook + * 点击外部关闭 Hook - 检测元素外部的点击事件 + * + * 遵循 Liquid Glass 设计系统原则: + * - 可访问性优先(支持 Escape 键关闭) + * - 性能优化(使用事件委托) + * - 触摸设备友好 + */ + +'use client'; + +import { RefObject, useEffect } from 'react'; + +/** + * 使用点击外部关闭 Hook + * @param ref - 要监听的元素引用 + * @param handler - 点击外部时触发的回调函数 + * @param enabled - 是否启用监听(默认为 true) + * + * @example + * ```tsx + * function Dropdown() { + * const [isOpen, setIsOpen] = useState(false); + * const dropdownRef = useRef(null); + * + * useClickOutside(dropdownRef, () => { + * setIsOpen(false); + * }); + * + * return ( + *
+ * + * {isOpen &&
Dropdown Content
} + *
+ * ); + * } + * ``` + */ +export function useClickOutside( + ref: RefObject, + handler: () => void, + enabled: boolean = true +): void { + useEffect(() => { + // 如果未启用或 ref 未挂载,则不执行 + if (!enabled || !ref.current) { + return; + } + + /** + * 处理点击事件 + * 检查点击目标是否在元素外部 + */ + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + + // 如果点击的目标不在 ref 元素内部,触发 handler + if (ref.current && !ref.current.contains(target)) { + handler(); + } + }; + + /** + * 处理 Escape 键 + * 增强可访问性,允许键盘用户关闭 + */ + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(); + } + }; + + // 延迟添加事件监听器,避免立即触发 + // 这样可以防止触发 handler 的同时又注册监听器导致立即关闭 + const timeoutId = setTimeout(() => { + // 监听鼠标点击和触摸事件 + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + // 监听 Escape 键 + document.addEventListener('keydown', handleEscapeKey); + }, 0); + + // 清理函数 + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [ref, handler, enabled]); +} + +/** + * 扩展版本:支持多个引用 + * 当需要排除多个元素时使用 + * + * @example + * ```tsx + * function DropdownWithTrigger() { + * const [isOpen, setIsOpen] = useState(false); + * const dropdownRef = useRef(null); + * const triggerRef = useRef(null); + * + * useClickOutsideMultiple([dropdownRef, triggerRef], () => { + * setIsOpen(false); + * }); + * + * return ( + * <> + * + * {isOpen && ( + *
Dropdown Content
+ * )} + * + * ); + * } + * ``` + */ +export function useClickOutsideMultiple( + refs: RefObject[], + handler: () => void, + enabled: boolean = true +): void { + useEffect(() => { + if (!enabled) { + return; + } + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + + // 检查点击是否在任何一个 ref 元素内部 + const isClickInside = refs.some(ref => + ref.current && ref.current.contains(target) + ); + + // 如果点击在所有元素外部,触发 handler + if (!isClickInside) { + handler(); + } + }; + + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(); + } + }; + + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + document.addEventListener('keydown', handleEscapeKey); + }, 0); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [refs, handler, enabled]); +} diff --git a/lib/hooks/useMediaQuery.ts b/lib/hooks/useMediaQuery.ts new file mode 100644 index 0000000..ef15d27 --- /dev/null +++ b/lib/hooks/useMediaQuery.ts @@ -0,0 +1,119 @@ +/** + * useMediaQuery Hook + * 媒体查询 Hook - 监听浏览器媒体查询状态变化 + * + * 遵循 Liquid Glass 设计系统原则: + * - 响应式设计优先 + * - 性能优化(使用 matchMedia API) + * - SSR 兼容 + */ + +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * 使用媒体查询 Hook + * @param query - CSS 媒体查询字符串(例如:'(min-width: 768px)') + * @returns boolean - 媒体查询是否匹配 + * + * @example + * ```tsx + * // 检测是否为移动设备 + * const isMobile = useMediaQuery('(max-width: 768px)'); + * + * // 检测是否为暗色模式 + * const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + * + * // 检测是否为触摸设备 + * const isTouchDevice = useMediaQuery('(hover: none) and (pointer: coarse)'); + * ``` + */ +export function useMediaQuery(query: string): boolean { + // 初始状态处理(SSR 兼容) + const getMatches = (query: string): boolean => { + // 防止 SSR 错误 + if (typeof window === 'undefined') { + return false; + } + return window.matchMedia(query).matches; + }; + + const [matches, setMatches] = useState(getMatches(query)); + + useEffect(() => { + // 防止 SSR 错误 + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia(query); + + // 更新状态的处理函数 + const handleChange = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + // 初始化时检查一次 + setMatches(mediaQuery.matches); + + // 添加监听器(现代浏览器使用 addEventListener) + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + } else { + // 兼容旧版浏览器(已废弃的 API) + // @ts-ignore + mediaQuery.addListener(handleChange); + } + + // 清理函数 + return () => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', handleChange); + } else { + // @ts-ignore + mediaQuery.removeListener(handleChange); + } + }; + }, [query]); + + return matches; +} + +/** + * 预定义的断点常量(基于 Tailwind CSS) + * 与 Liquid Glass 设计系统的响应式规范一致 + */ +export const BREAKPOINTS = { + sm: '(min-width: 640px)', + md: '(min-width: 768px)', + lg: '(min-width: 1024px)', + xl: '(min-width: 1280px)', + '2xl': '(min-width: 1536px)', + + // 反向查询(小于某个断点) + maxSm: '(max-width: 639px)', + maxMd: '(max-width: 767px)', + maxLg: '(max-width: 1023px)', + maxXl: '(max-width: 1279px)', + max2xl: '(max-width: 1535px)', + + // 特殊查询 + mobile: '(max-width: 768px)', + tablet: '(min-width: 768px) and (max-width: 1024px)', + desktop: '(min-width: 1024px)', + touch: '(hover: none) and (pointer: coarse)', + darkMode: '(prefers-color-scheme: dark)', + lightMode: '(prefers-color-scheme: light)', + reducedMotion: '(prefers-reduced-motion: reduce)', +} as const; + +/** + * 便捷的断点检测 Hooks + */ +export const useIsMobile = () => useMediaQuery(BREAKPOINTS.mobile); +export const useIsTablet = () => useMediaQuery(BREAKPOINTS.tablet); +export const useIsDesktop = () => useMediaQuery(BREAKPOINTS.desktop); +export const useIsTouchDevice = () => useMediaQuery(BREAKPOINTS.touch); +export const usePrefersDarkMode = () => useMediaQuery(BREAKPOINTS.darkMode); +export const usePrefersReducedMotion = () => useMediaQuery(BREAKPOINTS.reducedMotion);