Arrange the rail: drag pages into your own order, kept with the account

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>
This commit is contained in:
Mitchell-Johnson
2026-09-22 19:59:25 -07:00
co-authored by Claude Fable 5.1
parent b8ea7abcc7
commit 7e3e916f25
11 changed files with 122 additions and 22 deletions
+44 -5
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
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.
@@ -6,14 +7,48 @@ export interface Group { id: string; label: string; icon: ReactNode; soon?: bool
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">
<nav className={'rail' + (arranging ? ' arranging' : '')}>
{header}
{groups.map((g) => (
<div key={g.id} className={'group' + (g.soon ? ' soon' : '')}>
{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) onView(g.id) }}>
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>}
@@ -21,6 +56,10 @@ export default function Rail({ groups, view, header, badges, onView }: Props) {
</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>
)
}