The close cross on a detail pane is drawn, not a glyph, and sits in no box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
66 lines
3.3 KiB
TypeScript
66 lines
3.3 KiB
TypeScript
import { ReactNode, useRef, useState } from 'react'
|
|
import { orderBy, resetRailOrder, setRailOrder, useRailOrder } from './railorder'
|
|
|
|
// Every group is a page: clicking it opens it. `soon` groups are inert placeholders
|
|
// for the roadmap.
|
|
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean }
|
|
|
|
interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void }
|
|
|
|
// The pages are in the order the account keeps (railorder.ts). "Arrange" at the foot puts a
|
|
// handle on each; drag one up or down — pointer events, so a finger works as well as a mouse —
|
|
// and the order is saved with the UI prefs when it is let go.
|
|
export default function Rail({ groups, view, header, badges, onView }: Props) {
|
|
const saved = useRailOrder()
|
|
const [arranging, setArranging] = useState(false)
|
|
const [drag, setDrag] = useState<{ id: string; order: string[] } | null>(null)
|
|
const rows = useRef(new Map<string, HTMLElement>())
|
|
const ordered = drag ? orderBy(groups, drag.order) : orderBy(groups, saved)
|
|
|
|
const start = (id: string) => (e: React.PointerEvent<HTMLElement>) => {
|
|
e.preventDefault()
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
setDrag({ id, order: ordered.map((g) => g.id) })
|
|
}
|
|
const move = (e: React.PointerEvent<HTMLElement>) => {
|
|
if (!drag) return
|
|
// where the pointer is among the rows: the row whose middle it has crossed
|
|
const y = e.clientY
|
|
const order = drag.order.slice()
|
|
const from = order.indexOf(drag.id)
|
|
let to = from
|
|
order.forEach((id, i) => {
|
|
const el = rows.current.get(id)
|
|
if (!el || id === drag.id) return
|
|
const r = el.getBoundingClientRect()
|
|
const mid = r.top + r.height / 2
|
|
if (i < from && y < mid && i < to) to = i
|
|
if (i > from && y > mid && i > to) to = i
|
|
})
|
|
if (to !== from) { order.splice(from, 1); order.splice(to, 0, drag.id); setDrag({ id: drag.id, order }) }
|
|
}
|
|
const end = () => { if (drag) { setRailOrder(drag.order); setDrag(null) } }
|
|
|
|
return (
|
|
<nav className={'rail' + (arranging ? ' arranging' : '')}>
|
|
{header}
|
|
{ordered.map((g) => (
|
|
<div key={g.id} ref={(el) => { if (el) rows.current.set(g.id, el); else rows.current.delete(g.id) }} className={'group' + (g.soon ? ' soon' : '') + (drag?.id === g.id ? ' dragging' : '')}>
|
|
<button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon}
|
|
onClick={() => { if (!g.soon && !arranging) onView(g.id) }}>
|
|
<span className="handle" title="drag to move" onPointerDown={start(g.id)} onPointerMove={move} onPointerUp={end} onPointerCancel={end}>⋮⋮</span>
|
|
<span className="gicon">{g.icon}</span>
|
|
<span>{g.label}</span>
|
|
{g.soon && <span className="badge">soon</span>}
|
|
{!!badges?.[g.id] && <span className="rail-badge" title={`${badges[g.id]} firing`}>{badges[g.id]}</span>}
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="rail-foot">
|
|
<button className={arranging ? 'on' : ''} title="put the pages in your own order" onClick={() => setArranging(!arranging)}>{arranging ? 'Done' : '⇅ Arrange'}</button>
|
|
{arranging && saved.length > 0 && <button onClick={() => resetRailOrder()}>Reset</button>}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|