feat: overhaul mobile management layouts

This commit is contained in:
Cita
2026-03-21 20:42:25 +08:00
parent 6a19d809f0
commit 09fbf258e2
55 changed files with 4815 additions and 1030 deletions
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
type MobileBatchBarProps = {
info: React.ReactNode;
children: React.ReactNode;
};
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return (
<div className="mobile-actions-bar mobile-batch-bar">
<span className="mobile-actions-info">{info}</span>
<div className="mobile-actions-row">{children}</div>
</div>
);
}
+31 -7
View File
@@ -2,30 +2,54 @@ import React from 'react';
type MobileCardProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
headerActions?: React.ReactNode;
footerActions?: React.ReactNode;
compact?: boolean;
className?: string;
bodyClassName?: string;
children: React.ReactNode;
};
type MobileFieldProps = {
label: React.ReactNode;
value: React.ReactNode;
stacked?: boolean;
};
export function MobileCard({ title, actions, children }: MobileCardProps) {
export function MobileCard({
title,
subtitle,
actions,
headerActions,
footerActions,
compact = false,
className = '',
bodyClassName = '',
children,
}: MobileCardProps) {
const resolvedHeaderActions = headerActions ?? actions;
const cardClassName = ['mobile-card', compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
const cardBodyClassName = ['mobile-card-body', bodyClassName].filter(Boolean).join(' ');
return (
<div className="mobile-card">
<div className={cardClassName}>
<div className="mobile-card-header">
<div className="mobile-card-title">{title}</div>
{actions ? <div className="mobile-card-actions">{actions}</div> : null}
<div className="mobile-card-title-block">
<div className="mobile-card-title">{title}</div>
{subtitle ? <div className="mobile-card-subtitle">{subtitle}</div> : null}
</div>
{resolvedHeaderActions ? <div className="mobile-card-header-actions">{resolvedHeaderActions}</div> : null}
</div>
<div className="mobile-card-body">{children}</div>
<div className={cardBodyClassName}>{children}</div>
{footerActions ? <div className="mobile-card-footer-actions">{footerActions}</div> : null}
</div>
);
}
export function MobileField({ label, value }: MobileFieldProps) {
export function MobileField({ label, value, stacked = false }: MobileFieldProps) {
return (
<div className="mobile-field">
<div className={`mobile-field${stacked ? ' is-stacked' : ''}`}>
<div className="mobile-field-label">{label}</div>
<div className="mobile-field-value">{value}</div>
</div>
+54 -7
View File
@@ -1,14 +1,27 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
};
function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
function MobileDrawer({
open,
onClose,
children,
title,
closeLabel = '关闭导航',
side = 'left',
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
useEffect(() => {
if (open) {
@@ -29,21 +42,55 @@ function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
onClose();
}, [onClose]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleClose, open]);
if (!shouldRender) return null;
return (
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<button
type="button"
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-label="Close navigation"
aria-hidden="true"
/>
<div className="mobile-drawer-panel" role="dialog" aria-modal="true">
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<div className="mobile-drawer-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
×
</button>
</div>
{children}
</div>
</div>
);
const portalTarget = typeof document !== 'undefined' ? document.body : null;
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import MobileDrawer from './MobileDrawer.js';
type MobileFilterSheetProps = {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
};
export default function MobileFilterSheet({
open,
onClose,
title = '筛选',
children,
}: MobileFilterSheetProps) {
return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
<div className="mobile-filter-panel">
{children}
</div>
</MobileDrawer>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
type ResponsiveFormGridProps = {
columns?: 1 | 2 | 3;
children: React.ReactNode;
className?: string;
};
export default function ResponsiveFormGrid({
columns = 2,
children,
className,
}: ResponsiveFormGridProps) {
const classes = [
'responsive-form-grid',
`responsive-form-grid-${columns}`,
className,
].filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import MobileBatchBar from './MobileBatchBar.js';
describe('MobileBatchBar', () => {
it('renders info text and action content inside the shared batch bar shell', () => {
const root = create(
<MobileBatchBar info="已选 2 项">
<button type="button">批量删除</button>
</MobileBatchBar>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('已选 2 项');
expect(text).toContain('批量删除');
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
});
});
+22 -2
View File
@@ -3,10 +3,17 @@ import { create } from 'react-test-renderer';
import { MobileCard, MobileField } from './MobileCard.js';
describe('MobileCard', () => {
it('renders title and fields', () => {
it('renders separate header and footer action slots plus stacked fields', () => {
const root = create(
<MobileCard title="CardTitle">
<MobileCard
title="CardTitle"
subtitle="CardSubtitle"
compact
headerActions={<span>Meta</span>}
footerActions={<button type="button">Action</button>}
>
<MobileField label="Status" value="OK" />
<MobileField label="URL" value="https://example.com/very/long/path" stacked />
</MobileCard>,
);
@@ -16,7 +23,20 @@ describe('MobileCard', () => {
.join('');
expect(text).toContain('CardTitle');
expect(text).toContain('CardSubtitle');
expect(text).toContain('Meta');
expect(text).toContain('Status');
expect(text).toContain('OK');
expect(text).toContain('Action');
const headerActions = root.root.find((node) => node.props?.className === 'mobile-card-header-actions');
const footerActions = root.root.find((node) => node.props?.className === 'mobile-card-footer-actions');
const stackedField = root.root.find((node) => node.props?.className === 'mobile-field is-stacked');
const compactCard = root.root.find((node) => node.props?.className === 'mobile-card is-compact');
expect(headerActions).toBeTruthy();
expect(footerActions).toBeTruthy();
expect(stackedField).toBeTruthy();
expect(compactCard).toBeTruthy();
});
});
+64 -15
View File
@@ -1,25 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, create } from 'react-test-renderer';
import MobileDrawer from './MobileDrawer.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileDrawer', () => {
it('renders content when open and closes on backdrop click', () => {
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
const onClose = vi.fn();
const root = create(
<MobileDrawer open onClose={onClose}>
<div>DrawerContent</div>
</MobileDrawer>,
);
let root: ReturnType<typeof create> | null = null;
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={onClose} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
expect(text).toContain('DrawerContent');
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
const backdrop = root.root.find((node) => node.props?.className === 'mobile-drawer-backdrop');
backdrop.props.onClick();
expect(onClose).toHaveBeenCalledTimes(1);
expect(text).toContain('DrawerContent');
expect(text).toContain('导航菜单');
expect(document.body.style.overflow).toBe('hidden');
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
expect(backdrop.type).toBe('div');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className === 'mobile-drawer-close'
));
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
expect(document.body.style.overflow).toBe('');
vi.unstubAllGlobals();
}
});
it('defines independent right-side drawer animations in shared css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('@keyframes drawer-slide-in-right');
expect(css).toContain('@keyframes drawer-slide-out-right');
expect(css).toMatch(/\.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-in-right/);
expect(css).toMatch(/\.mobile-drawer-root\.is-closing \.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-out-right/);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import MobileFilterSheet from './MobileFilterSheet.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileFilterSheet', () => {
it('wraps content with the shared filter panel shell', () => {
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
const root = create(
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
<div>FilterContent</div>
</MobileFilterSheet>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('筛选条件');
expect(text).toContain('FilterContent');
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
} finally {
vi.unstubAllGlobals();
}
});
});
+3
View File
@@ -0,0 +1,3 @@
export const MOBILE_BREAKPOINT = 768;
export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveFormGrid from './ResponsiveFormGrid.js';
describe('ResponsiveFormGrid', () => {
it('applies the shared responsive grid class contract', () => {
const root = create(
<ResponsiveFormGrid columns={3}>
<div>Field A</div>
<div>Field B</div>
</ResponsiveFormGrid>,
);
const container = root.root.findByType('div');
expect(container.props.className).toContain('responsive-form-grid');
expect(container.props.className).toContain('responsive-form-grid-3');
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useIsMobile } from './useIsMobile.js';
function Probe() {
return <div data-mobile={String(useIsMobile())} />;
}
describe('useIsMobile', () => {
const widthRef = { current: 768 };
const listeners = new Map<string, Set<() => void>>();
beforeEach(() => {
widthRef.current = 768;
listeners.clear();
vi.stubGlobal('window', {
innerWidth: widthRef.current,
addEventListener: (event: string, handler: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(handler);
},
removeEventListener: (event: string, handler: () => void) => {
listeners.get(event)?.delete(handler);
},
dispatchEvent: (event: { type: string }) => {
listeners.get(event.type)?.forEach((handler) => handler());
return true;
},
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('does not throw when a partial window mock lacks resize listener APIs', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('does not throw when matchMedia only exposes addEventListener', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
matchMedia: () => ({
matches: true,
media: '(max-width: 768px)',
addEventListener: () => {},
}),
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('treats 767 and 768 as mobile, but 769 as desktop', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
const probe = root.root.findByType('div');
expect(probe.props['data-mobile']).toBe('true');
widthRef.current = 767;
window.innerWidth = 767;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 768;
window.innerWidth = 768;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 769;
window.innerWidth = 769;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('false');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
+27 -4
View File
@@ -1,16 +1,39 @@
import { useEffect, useState } from 'react';
import { MOBILE_BREAKPOINT, MOBILE_MEDIA_QUERY } from './mobileLayout.js';
export function useIsMobile(breakpoint = 768) {
export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(() => (
typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
typeof window !== 'undefined' ? window.innerWidth <= breakpoint : false
));
useEffect(() => {
if (typeof window === 'undefined') return;
const update = () => setIsMobile(window.innerWidth < breakpoint);
const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
update();
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', update);
return () => {
if (typeof media.removeEventListener === 'function') {
media.removeEventListener('change', update);
}
};
}
const addResizeListener = typeof window.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function';
if (!addResizeListener) return;
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
return () => {
if (removeResizeListener) {
window.removeEventListener('resize', update);
}
};
}, [breakpoint]);
return isMobile;