Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* The editor's side rail, and the answer to "four things want one rail".
|
||||
*
|
||||
* The inspector, history, and (later) the journal and chat panel all want the
|
||||
* same strip of screen. Rather than each bolting on its own chrome, they are
|
||||
* tabs in one rail — which keeps the canvas one width instead of a different
|
||||
* width per panel, and means adding the journal or chat is adding a tab.
|
||||
*
|
||||
* Two constraints shaped it:
|
||||
*
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the
|
||||
* rail is a fixed 20rem column and closes completely when nothing needs it.
|
||||
*
|
||||
* On a phone the same tabs render in a bottom sheet, which is where the
|
||||
* inspector already lived.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Rendered lazily: only the active tab's content is built. */
|
||||
render: () => ReactNode
|
||||
/** Shown as a dot on the tab — a count, or true for a plain marker. */
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
export function EditorRail({
|
||||
tabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
|
||||
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onActivate(tab.id)}
|
||||
aria-current={tab.id === active.id ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
|
||||
{tab.badge === true ? '•' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user