refactor: replace preset detail modal layoutId morph with bottom sheet slide-up + category crossfade

- Remove layoutId from preset card in game-page-client.tsx and modal in preset-detail-modal.tsx to eliminate text smearing during morph animation.
- Replace modal scale/opacity spring animation with bottom sheet y-slide spring (y: 100% → 0).
- Wrap settings table content in AnimatePresence mode='wait' with keyed motion.div for crossfade when switching categories.
This commit is contained in:
2026-04-28 09:24:08 +08:00
parent 1e41531834
commit f502d120a7
2 changed files with 66 additions and 59 deletions
-1
View File
@@ -751,7 +751,6 @@ export function GamePageClient({
return ( return (
<motion.div <motion.div
key={preset.id} key={preset.id}
layoutId={preset.id}
onClick={() => setSelectedPresetId(preset.id)} onClick={() => setSelectedPresetId(preset.id)}
className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${ className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
raw raw
+66 -58
View File
@@ -164,14 +164,13 @@ export function PresetDetailModal({
> >
{/* Modal card */} {/* Modal card */}
<motion.div <motion.div
layoutId={preset.id}
className="relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col" className="relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col"
initial={{ scale: 0.95, opacity: 0 }} initial={{ y: "100%", opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }} exit={{ y: "100%", opacity: 0 }}
transition={{ transition={{
type: "spring", type: "spring",
damping: 25, damping: 30,
stiffness: 300, stiffness: 300,
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -427,62 +426,71 @@ export function PresetDetailModal({
{/* Right panel */} {/* Right panel */}
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5">
{hasCategories ? ( {hasCategories ? (
<div className="flex flex-col gap-4"> <AnimatePresence mode="wait">
{/* Category navigation */} <motion.div
<div className="flex items-center justify-between"> key={currentCategory?.category ?? "empty"}
<button initial={{ opacity: 0 }}
onClick={goPrevCategory} animate={{ opacity: 1 }}
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer" exit={{ opacity: 0 }}
aria-label="Previous category" transition={{ duration: 0.15 }}
> className="flex flex-col gap-4"
<ChevronLeftIcon className="h-4 w-4 text-text/50" /> >
</button> {/* Category navigation */}
<span className="text-sm font-medium text-text/80"> <div className="flex items-center justify-between">
{currentCategory?.category}{" "} <button
<span className="text-text/40"> onClick={goPrevCategory}
({activeCategoryIndex + 1}/{categories.length}) className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
aria-label="Previous category"
>
<ChevronLeftIcon className="h-4 w-4 text-text/50" />
</button>
<span className="text-sm font-medium text-text/80">
{currentCategory?.category}{" "}
<span className="text-text/40">
({activeCategoryIndex + 1}/{categories.length})
</span>
</span> </span>
</span> <button
<button onClick={goNextCategory}
onClick={goNextCategory} className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer" aria-label="Next category"
aria-label="Next category" >
> <ChevronRightIcon className="h-4 w-4 text-text/50" />
<ChevronRightIcon className="h-4 w-4 text-text/50" /> </button>
</button> </div>
</div>
{/* Settings table */} {/* Settings table */}
<div className="rounded-lg border border-border overflow-hidden"> <div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-text/3"> <thead className="bg-text/3">
<tr> <tr>
<th className="text-left px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50"> <th className="text-left px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50">
Setting Setting
</th> </th>
<th className="text-right px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50"> <th className="text-right px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50">
Value Value
</th> </th>
</tr>
</thead>
<tbody>
{currentCategory?.settings.map((setting, sIdx) => (
<tr
key={sIdx}
className="border-t border-border"
>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70">
{setting.title}
</td>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-right font-medium text-text">
{formatValue(setting.value)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {currentCategory?.settings.map((setting, sIdx) => (
</div> <tr
</div> key={sIdx}
className="border-t border-border"
>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70">
{setting.title}
</td>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-right font-medium text-text">
{formatValue(setting.value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</AnimatePresence>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2"> <div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2">
<p className="text-sm text-text/40"> <p className="text-sm text-text/40">