Skip to content
Clone
main
168 lines 6.4 KB
import { useT } from '@app/i18n'
import { type Icon, IconExternalLink as SquareArrowOutUpRight, IconX as X } from '@tabler/icons-react'
import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
export interface SettingsSection {
id: string
label: string
/** Row glyph, mirroring the reference settings dialog. */
icon?: Icon
/** Rows sharing a group sit under one small caption. */
group?: string
/** Rows that leave the dialog for a full page instead of switching panes. */
href?: string
}
export interface SettingsModalShellProps {
/** Nav heading, e.g. 用户设置 / 社区设置. Also the dialog's accessible name. */
title: string
sections: SettingsSection[]
active: string
onSelect: (id: string) => void
onClose: () => void
children: ReactNode
/** Replaces the section list while a section runs its own sub-navigation. */
navOverride?: ReactNode
/** Swap key for the content pane (defaults to the active section id). */
contentToken?: string
}
/**
* The large two-column settings overlay shared by every settings surface, so a
* community's settings cannot drift away from the user's own in size or spacing.
*
* The backdrop deliberately does NOT close: these forms hold unsaved edits
* across many sections, and click-away is far too lossy to be the dismiss path.
*/
export function SettingsModalShell({
title,
sections,
active,
onSelect,
onClose,
children,
navOverride,
contentToken,
}: SettingsModalShellProps) {
const { t } = useT()
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const activeSection =
sections.find((item) => item.id === active && !item.href) ?? sections.find((item) => !item.href) ?? sections[0]
return (
<div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-6">
<div role="dialog" aria-modal aria-label={title} className="settings-dialog text-shell-ink">
<nav className="settings-nav" aria-label={t('ui.settingsCategories', { title })}>
<FadeSwap token={navOverride ? 'sub' : 'sections'} className="settings-nav-body">
{navOverride ?? (
<>
<p className="settings-nav-title">{title}</p>
{sections.map((item, index) => {
const Icon = item.icon
const opensGroup = Boolean(item.group) && item.group !== sections[index - 1]?.group
return (
<Fragment key={item.id}>
{opensGroup ? <p className="settings-nav-caption">{item.group}</p> : null}
{item.href ? (
<Link
to={item.href}
onClick={onClose}
className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
>
{Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
<span className="truncate">{item.label}</span>
<SquareArrowOutUpRight
className="ml-auto h-3.5 w-3.5 shrink-0 text-shell-ink-muted"
aria-hidden
/>
</Link>
) : (
<button
type="button"
aria-current={active === item.id ? 'page' : undefined}
onClick={() => onSelect(item.id)}
className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
>
{Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
<span className="truncate">{item.label}</span>
</button>
)}
</Fragment>
)
})}
</>
)}
</FadeSwap>
</nav>
<div className="settings-pane">
<header className="settings-pane-header">
<h2
key={activeSection?.id}
className={`settings-swap-in ${activeSection?.id === 'danger' ? 'text-[#f23f43]' : ''}`}
>
{activeSection?.label ?? title}
</h2>
<button
type="button"
aria-label={t('ui.closeSettings')}
onClick={onClose}
className="settings-close workspace-icon-button"
>
<X className="h-5 w-5" />
</button>
</header>
<FadeSwap token={contentToken ?? active} className="settings-content">
{children}
</FadeSwap>
</div>
</div>
</div>
)
}
/**
* Cross-fades a swapped subtree: the outgoing tree is parked on top while the
* replacement fades in, so the pane never flashes empty and never shifts.
*/
function FadeSwap({ token, className, children }: { token: string; className?: string; children: ReactNode }) {
const [current, setCurrent] = useState<{ token: string; node: ReactNode }>({ token, node: children })
const [outgoing, setOutgoing] = useState<{ token: string; node: ReactNode } | null>(null)
const frame = useRef<HTMLDivElement>(null)
const shown = useRef({ token, node: children })
const exitTimer = useRef<number | undefined>(undefined)
useEffect(() => {
const previous = shown.current
shown.current = { token, node: children }
setCurrent(shown.current)
// Same view: only refresh the children (async data, edits) — no fade.
if (token === previous.token) return
setOutgoing(previous)
frame.current?.scrollTo({ top: 0 })
window.clearTimeout(exitTimer.current)
exitTimer.current = window.setTimeout(() => setOutgoing(null), 220)
}, [token, children])
useEffect(() => () => window.clearTimeout(exitTimer.current), [])
return (
<div ref={frame} className={`settings-swap ${className ?? ''}`}>
{outgoing ? (
<div key={outgoing.token} className="settings-swap-out" aria-hidden>
{outgoing.node}
</div>
) : null}
<div key={current.token} className="settings-swap-in">
{current.node}
</div>
</div>
)
}