Replace rolodex with animated expandable list for mobile
Each store is a compact row with a route-colored left accent bar. Tap to bloom open — smooth height animation, route-colored background glow and border highlight, tags/meta/actions revealed. Rows stagger in on load/filter change. No swipe gestures needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
623c3783b5
commit
57cb35d565
3 changed files with 180 additions and 3 deletions
|
|
@ -7,7 +7,7 @@ import { StatsBar } from './components/StatsBar'
|
|||
import { FilterBar } from './components/FilterBar'
|
||||
import { StoreCard } from './components/StoreCard'
|
||||
import { StoreTable } from './components/StoreTable'
|
||||
import { MobileRolodex } from './components/MobileRolodex'
|
||||
import { MobileStoreList } from './components/MobileStoreList'
|
||||
import { StoreModal } from './components/StoreModal'
|
||||
import { DeleteConfirm } from './components/DeleteConfirm'
|
||||
import { Pagination } from './components/Pagination'
|
||||
|
|
@ -186,9 +186,9 @@ export default function App() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: rolodex */}
|
||||
{/* Mobile: animated expandable list */}
|
||||
<div className="sm:hidden">
|
||||
<MobileRolodex stores={stores} onEdit={setEditStore} onDelete={setDeleteTarget} />
|
||||
<MobileStoreList stores={stores} onEdit={setEditStore} onDelete={setDeleteTarget} />
|
||||
</div>
|
||||
|
||||
{/* Desktop: toggle between table and grid */}
|
||||
|
|
|
|||
169
src/components/MobileStoreList.tsx
Normal file
169
src/components/MobileStoreList.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Pencil, Trash2, ChevronDown, Tag, Calendar, Layers } from 'lucide-react'
|
||||
import type { Store } from '../types'
|
||||
import { RouteBadge } from './RouteBadge'
|
||||
import { TagChip } from './TagChip'
|
||||
import { parseTags, formatDate } from '../utils'
|
||||
|
||||
interface Props {
|
||||
stores: Store[]
|
||||
onEdit: (store: Store) => void
|
||||
onDelete: (store: Store) => void
|
||||
}
|
||||
|
||||
const ROUTE_GLOW: Record<string, string> = {
|
||||
auto: 'rgba(34, 197, 94, 0.12)',
|
||||
ask: 'rgba(245, 158, 11, 0.12)',
|
||||
verify: 'rgba(59, 130, 246, 0.12)',
|
||||
ignore: 'rgba(107, 114, 128, 0.08)',
|
||||
}
|
||||
|
||||
const ROUTE_BORDER: Record<string, string> = {
|
||||
auto: '#22c55e',
|
||||
ask: '#f59e0b',
|
||||
verify: '#3b82f6',
|
||||
ignore: '#4b5563',
|
||||
}
|
||||
|
||||
function StoreRow({
|
||||
store,
|
||||
index,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
store: Store
|
||||
index: number
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onEdit: (s: Store) => void
|
||||
onDelete: (s: Store) => void
|
||||
}) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const [bodyHeight, setBodyHeight] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) setBodyHeight(bodyRef.current.scrollHeight)
|
||||
}, [store])
|
||||
|
||||
const tags = parseTags(store.tags)
|
||||
const glow = ROUTE_GLOW[store.route] ?? ROUTE_GLOW.ignore
|
||||
const border = ROUTE_BORDER[store.route] ?? ROUTE_BORDER.ignore
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden transition-all duration-300 animate-row-in"
|
||||
style={{
|
||||
background: expanded ? glow : 'rgba(22, 27, 39, 0.6)',
|
||||
border: `1px solid ${expanded ? border + '55' : 'rgba(255,255,255,0.06)'}`,
|
||||
boxShadow: expanded ? `0 0 24px ${glow}` : 'none',
|
||||
animationDelay: `${Math.min(index * 35, 350)}ms`,
|
||||
}}
|
||||
>
|
||||
{/* Row header — always visible */}
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 text-left"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Route accent bar */}
|
||||
<div
|
||||
className="w-1 self-stretch rounded-full shrink-0 transition-all duration-300"
|
||||
style={{ background: border, opacity: expanded ? 1 : 0.5 }}
|
||||
/>
|
||||
|
||||
{/* Name + category */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate leading-snug">
|
||||
{store.store}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{store.category}</p>
|
||||
</div>
|
||||
|
||||
<RouteBadge route={store.route} size="sm" />
|
||||
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className="text-slate-500 shrink-0 transition-transform duration-300"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable body */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{ maxHeight: expanded ? bodyHeight + 'px' : '0px', opacity: expanded ? 1 : 0 }}
|
||||
>
|
||||
<div ref={bodyRef} className="px-4 pb-4 space-y-3">
|
||||
{/* Divider */}
|
||||
<div className="h-px" style={{ background: border + '30' }} />
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Layers size={11} className="text-slate-600" />
|
||||
{store.category}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Calendar size={11} className="text-slate-600" />
|
||||
{formatDate(store.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Tag size={11} className="text-slate-600 mt-1 shrink-0" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(t => <TagChip key={t} tag={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onEdit(store) }}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium text-slate-400 hover:text-blue-400 border border-white/6 hover:border-blue-500/30 hover:bg-blue-500/8 transition-all"
|
||||
>
|
||||
<Pencil size={12} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(store) }}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium text-slate-400 hover:text-red-400 border border-white/6 hover:border-red-500/30 hover:bg-red-500/8 transition-all"
|
||||
>
|
||||
<Trash2 size={12} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileStoreList({ stores, onEdit, onDelete }: Props) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
|
||||
// Reset expanded when store list changes (filter/search)
|
||||
useEffect(() => { setExpandedId(null) }, [stores])
|
||||
|
||||
function toggle(id: number) {
|
||||
setExpandedId(prev => prev === id ? null : id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{stores.map((store, i) => (
|
||||
<StoreRow
|
||||
key={store.id}
|
||||
store={store}
|
||||
index={i}
|
||||
expanded={expandedId === store.id}
|
||||
onToggle={() => toggle(store.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,3 +51,11 @@ body { margin: 0; }
|
|||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.animate-slide-in { animation: slide-in 0.2s ease-out; }
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-row-in {
|
||||
animation: row-in 0.25s ease-out both;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue