feat(web): open spaces as a plain chat on phones with a side drawer

Spaces, channels, files, members and the account move into a left drawer,
the top bar keeps only the essentials, and touch devices get a tap menu
instead of always-visible delete buttons. Also fixes the history checkboxes
in the channel settings sheet.
This commit is contained in:
justme committed 2026-10-16 05:34:38 +08:00
1 parent 06fe077ef1
commit 1db3795d6a
16 files changed
+383 -56

No files matched your search

+15 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, type MouseEvent } from 'react';
import { isLocalId, useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { useDownload } from '@/features/item/download';
@@ -7,6 +7,7 @@ import { senderLabel } from '@/lib/sender';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
import styles from './ChatPane.module.css';
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
@@ -18,6 +19,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
const username = useAccount((s) => s.username);
const download = useDownload();
const [downloading, setDownloading] = useState(false);
const [menu, setMenu] = useState<{ x: number; y: number }>();
if (!item) return null;
const sender = item.author;
const name = senderLabel(item);
@@ -31,7 +33,17 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
if (!item || downloading) return;
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
}
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
// 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮)
const actions: MenuAction[] = [
...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []),
...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []),
];
function tap(event: MouseEvent<HTMLElement>) {
if (!matchMedia('(hover: none)').matches || !actions.length || sending) return;
if ((event.target as HTMLElement).closest('button, a, input, progress') || window.getSelection()?.toString()) return;
setMenu({ x: event.clientX, y: event.clientY });
}
return <article onClick={tap} className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
@@ -52,5 +64,6 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
{menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null}
</article>;
}
+7 -1
View File
@@ -278,6 +278,11 @@
}
@media (width <= 899px) {
/* 手机顶栏已显示频道名 */
.head {
display: none;
}
.message {
grid-template-columns: 32px minmax(0, 1fr);
column-gap: var(--space-2);
@@ -298,8 +303,9 @@
}
}
/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */
@media (hover: none) {
.deleteAction {
opacity: 1;
display: none;
}
}
@@ -0,0 +1,61 @@
.scroll {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border-top: 1px solid var(--line);
}
.section h2 {
margin: 0 0 var(--space-1);
padding-inline: var(--space-2);
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.row:hover {
background: var(--accent-soft);
}
.row span {
flex: 1;
}
.row small {
color: var(--text-dim);
font-size: 12px;
}
.footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
padding-bottom: max(var(--space-2), env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
}
+48
View File
@@ -0,0 +1,48 @@
import { useChannels } from '@/store/channels';
import { useWorkspace } from '@/store/workspace';
import { AccountDock } from '@/features/home/AccountDock';
import { Icon, type IconName } from '@/ui/Icon';
import { ThemeMenu } from '@/ui/ThemeMenu';
import { SpaceHeader, SpaceRail } from './SpaceRail';
import styles from './MobileDrawer.module.css';
interface Props {
rail: { id?: string; onCreate: () => void; onJoin: () => void };
onSettings: () => void;
/** 频道列表(ThreadSidebar),由 Workspace 组装 */
threads: React.ReactNode;
onFiles: () => void;
onMembers: () => void;
onInvite?: () => void;
onShare: () => void;
onHome: () => void;
}
function Row({ icon, label, count, onClick }: { icon: IconName; label: string; count?: string; onClick: () => void }) {
return <button type="button" className={styles.row} onClick={onClick}>
<Icon name={icon} size={18} /><span>{label}</span>{count ? <small>{count}</small> : null}
</button>;
}
/** 手机侧边栏:空间、频道、本频道的文件与成员;账号与外观在底部 */
export function MobileDrawer({ rail, onSettings, threads, onFiles, onMembers, onInvite, onShare, onHome }: Props) {
const used = useWorkspace((s) => s.channel?.membersUsed);
const max = useWorkspace((s) => s.channel?.maxMembers);
const spaces = useChannels((s) => s.list.length);
return <>
<SpaceRail {...rail} />
<SpaceHeader onSettings={onSettings} />
<div className={styles.scroll}>
{threads}
<nav className={styles.section} aria-label="本频道">
<h2>本空间</h2>
<Row icon="folder" label="文件" onClick={onFiles} />
<Row icon="users" label="成员" count={used !== undefined ? `${used}/${max ?? '—'}` : undefined} onClick={onMembers} />
{onInvite ? <Row icon="plus" label="邀请成员" onClick={onInvite} /> : null}
<Row icon="share" label="分享" onClick={onShare} />
<Row icon="chevronLeft" label={spaces > 1 ? '回到首页' : '返回首页'} onClick={onHome} />
</nav>
</div>
<footer className={styles.footer}><AccountDock inline /><ThemeMenu /></footer>
</>;
}
@@ -0,0 +1,62 @@
.bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
min-height: var(--bar-h);
padding: 0 var(--space-2);
padding-top: env(safe-area-inset-top);
border-bottom: 1px solid var(--line);
background: var(--glass);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: var(--blur-bar);
backdrop-filter: var(--blur-bar);
}
.raw {
border-bottom-color: var(--warn);
}
.title {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.lock {
display: inline-flex;
color: var(--ok);
}
.warn {
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
background: var(--warn-soft);
color: var(--warn);
font-size: 12px;
}
.conn {
font-size: 12px;
}
.reconnecting {
color: var(--busy);
}
.offline {
color: var(--danger);
}
.active {
background: var(--accent-soft);
color: var(--busy);
}
+30
View File
@@ -0,0 +1,30 @@
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './MobileTopBar.module.css';
interface Props {
/** 聊天页是 ≡(开侧边栏);文件、成员页是 ‹(回聊天) */
view: 'chat' | 'files' | 'members';
title: string;
searchOpen: boolean;
filtering: boolean;
onMenu: () => void;
onBack: () => void;
onToggleSearch: () => void;
onMore: () => void;
}
/** 手机顶栏:只放最必要的几项,其余收进「更多」 */
export function MobileTopBar({ view, title, searchOpen, filtering, onMenu, onBack, onToggleSearch, onMore }: Props) {
const connection = useWorkspace((s) => s.connection);
const raw = useWorkspace((s) => s.channel?.mode === 'raw');
return <header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
{view === 'chat' ? <Button variant="ghost" icon="menu" label="打开侧边栏" onClick={onMenu} /> : <Button variant="ghost" icon="chevronLeft" label="返回聊天" onClick={onBack} />}
<h1 className={styles.title}>{title}</h1>
{raw ? <span className={styles.warn}>明文</span> : <span className={styles.lock} title="端到端加密"><Icon name="lock" size={14} /></span>}
{connection === 'online' ? null : <span className={`${styles.conn} ${styles[connection]}`} role="status">{connection === 'offline' ? '离线' : '重连中'}</span>}
<Button variant="ghost" icon="search" label="搜索文件" aria-pressed={searchOpen} className={filtering ? styles.active : undefined} onClick={onToggleSearch} />
<Button variant="ghost" icon="more" label="更多" onClick={onMore} />
</header>;
}
@@ -88,11 +88,6 @@
}
@media (width <= 899px) {
/* 手机顶栏已有空间名与设置入口 */
.header {
display: none;
}
.rail {
flex-direction: row;
justify-content: flex-start;
@@ -318,16 +318,6 @@
opacity: 1;
}
.threads {
flex-direction: row;
overflow-x: auto;
}
.thread {
flex: 0 0 auto;
max-width: 220px;
}
.folders {
max-height: 120px;
overflow-y: auto;
@@ -338,3 +328,22 @@
opacity: 1;
}
}
/* 勾选项:框在左、文字在右,同一行 */
.channelEditor .check {
flex-direction: row;
align-items: center;
gap: var(--space-2);
min-height: 36px;
color: var(--text);
font-size: 14px;
}
.channelEditor .check input {
flex: 0 0 18px;
width: 18px;
min-height: 18px;
height: 18px;
padding: 0;
accent-color: var(--accent);
}
+2 -2
View File
@@ -91,8 +91,8 @@ export function ThreadSidebar(props: Props) {
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select></label> : null}
{edit.kind === 'channel' && edit.id && props.owner ? <>
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
</> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={`${styles.groupActions} ${styles.modalActions}`}>
@@ -65,38 +65,11 @@
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
}
.mobile .navigation {
max-height: 30dvh;
flex-shrink: 0;
border-right: 0;
}
.mobileContent {
flex: 1;
overflow: hidden;
}
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
.tabs button {
flex: 1;
min-height: 44px;
border: 0;
background: var(--surface);
color: var(--text-dim);
font: inherit;
cursor: pointer;
}
.tabs button[aria-pressed="true"] {
border-bottom: 2px solid var(--accent);
color: var(--text);
}
.pendingCatalog {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
+36 -7
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { channelPath, navigate, sharePath } from '@/app/navigation';
import { useDownload } from '@/features/item/download';
@@ -10,7 +10,11 @@ import type { FileUploadOptions } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button';
import { Drawer } from '@/ui/Drawer';
import { Icon, type IconName } from '@/ui/Icon';
import { SpaceHeader, SpaceRail } from './SpaceRail';
import { MobileDrawer } from './MobileDrawer';
import { MobileTopBar } from './MobileTopBar';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatPane } from './ChatPane';
import { FileExplorer } from './FileExplorer';
@@ -66,6 +70,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
const [sideTab, setSideTab] = useState<SideTab>('files');
const [inviteOpen, setInviteOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [memberRevision, setMemberRevision] = useState(0);
const [fileWidth, setFileWidth] = useState<number>();
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
@@ -110,11 +116,19 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
}
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
// 手机聊天页的账号入口在侧边栏,全局浮窗据此让位
useEffect(() => {
if (desktop) return;
document.documentElement.dataset.chatShell = '';
return () => { delete document.documentElement.dataset.chatShell; };
}, [desktop]);
const channelId = channel?.id;
useEffect(() => { setDrawerOpen(false); }, [channelId]);
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
{desktop ? <Notepad /> : null}</>;
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
@@ -129,13 +143,28 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
</>;
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
const moreRows: { icon: IconName; label: string; run: () => void }[] = [
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
{ icon: 'share', label: '分享', run: () => setPanelOpen(true) },
...(owner ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []),
];
const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`;
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
<TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
{desktop ? <TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : <MobileTopBar view={mobileView} title={mobileTitle} searchOpen={searchOpen} filtering={isFiltering(filter)} onMenu={() => setDrawerOpen(true)} onBack={() => setMobileView('chat')}
onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />}
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
: <main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main>}
{/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */}
{!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}>
<MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }}
onSettings={() => setSettingsOpen(true)} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} />
</Drawer> : null}
{!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null}
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
@@ -127,3 +127,32 @@
min-height: 40px;
}
}
/* 手机聊天页由侧边栏承载账号入口,浮窗让位 */
@media (width <= 899px) {
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
:global([data-chat-shell]) .dock:not(.inline) {
display: none;
}
}
.inline {
position: static;
flex: 1;
width: auto;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
}
.inline .profile {
flex: 1;
width: auto;
justify-content: flex-start;
padding: var(--space-1) var(--space-2);
}
.inline .profileText {
display: flex;
}
+3 -2
View File
@@ -5,7 +5,8 @@ import { Sheet } from '@/ui/Sheet';
import { AccountSettings } from './AccountSettings';
import styles from './AccountDock.module.css';
export function AccountDock() {
/** inline:嵌进手机侧边栏底部,不再浮在页面上 */
export function AccountDock({ inline = false }: { inline?: boolean }) {
const [settingsOpen, setSettingsOpen] = useState(false);
const username = useAccount((s) => s.username);
const personal = useAccount((s) => s.personal);
@@ -17,7 +18,7 @@ export function AccountDock() {
const openSettings = () => setSettingsOpen(true);
return <>
<section className={styles.dock} aria-label="当前账号">
<section className={`${styles.dock} ${inline ? styles.inline : ''}`} aria-label="当前账号">
<button className={styles.profile} type="button" onClick={openSettings} aria-label={`打开 ${displayName} 的账号设置`}>
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" /> : <span className={styles.avatarFallback}>{initial}</span>}
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
+40
View File
@@ -0,0 +1,40 @@
.scrim {
position: fixed;
inset: 0;
z-index: 50;
background: var(--scrim);
animation: drawer-scrim-in 0.15s ease-out;
}
.drawer {
display: flex;
flex-direction: column;
width: min(320px, 86vw);
height: 100%;
padding-top: env(safe-area-inset-top);
border-right: 1px solid var(--line);
background: var(--bg);
box-shadow: var(--shadow-sheet);
outline: none;
animation: drawer-in 0.2s ease-out;
}
@keyframes drawer-scrim-in {
from {
opacity: 0;
}
}
@keyframes drawer-in {
from {
translate: -24px 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.scrim,
.drawer {
animation: none;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackToClose } from '@/lib/useBackToClose';
import styles from './Drawer.module.css';
interface Props { label: string; onClose: () => void; children: ReactNode }
/** 手机的左侧抽屉:遮罩是纯色,返回键关闭(与 Sheet 一致) */
export function Drawer({ label, onClose, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const close = useRef(onClose);
useEffect(() => { close.current = onClose; }, [onClose]);
useBackToClose(onClose);
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
ref.current?.focus();
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close.current(); };
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('keydown', onKey); prev?.focus(); };
}, []);
return createPortal(
<div className={styles.scrim} onClick={onClose}>
<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-label={label} className={styles.drawer} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
+2
View File
@@ -21,6 +21,8 @@ const PATHS = {
phone: 'M8 3h8v18H8z M11 18h2',
tablet: 'M5 4h14v16H5z M11 17h2',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
menu: 'M4 7h16 M4 12h16 M4 17h16',
users: 'M9 11a3 3 0 100-6 3 3 0 000 6z M3 19a6 6 0 0112 0 M16 6a3 3 0 010 6 M18 19a5 5 0 00-3-4.6',
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8z M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',