forked from boos/BoosAPI
feat: overhaul mobile management layouts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user