mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
feat: Implement responsive design improvements and add custom hooks for keyboard navigation and click outside detection
This commit is contained in:
+12
-12
@@ -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<HTMLElement>) {
|
||||
// 实现焦点陷阱
|
||||
}
|
||||
```
|
||||
- [ ] 创建 `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<HTMLElement>, handler: () => void) {
|
||||
// 点击外部关闭
|
||||
|
||||
@@ -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 */}
|
||||
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 md:gap-4 lg:gap-6 ${className}`}
|
||||
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6 max-w-[1920px] mx-auto ${className}`}
|
||||
role="list"
|
||||
aria-label="视频搜索结果"
|
||||
>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
*
|
||||
* useClickOutside(dropdownRef, () => {
|
||||
* setIsOpen(false);
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <div ref={dropdownRef}>
|
||||
* <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
|
||||
* {isOpen && <div>Dropdown Content</div>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
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<HTMLDivElement>(null);
|
||||
* const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
*
|
||||
* useClickOutsideMultiple([dropdownRef, triggerRef], () => {
|
||||
* setIsOpen(false);
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
|
||||
* Toggle
|
||||
* </button>
|
||||
* {isOpen && (
|
||||
* <div ref={dropdownRef}>Dropdown Content</div>
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutsideMultiple<T extends HTMLElement = HTMLElement>(
|
||||
refs: RefObject<T>[],
|
||||
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]);
|
||||
}
|
||||
@@ -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<boolean>(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);
|
||||
Reference in New Issue
Block a user