GraphSidebar.tsx7.5 KBView on GitHub 'use client';
/**
* The canvas's left rail — everything you DO to a graph, as three glyphs.
*
* ── What this replaced, and why ──
*
* There was a labelled "Graph" button in the top-left corner whose popover held the two real
* actions (add somebody, tidy the layout) under a heading, with a read-only "Conventions" list
* below them. Two problems, one cause: the actions were two clicks deep behind a word that
* names the whole document rather than anything you can do to it, and the corner it sat in is
* where a reader's eye lands first on a canvas that is meant to be looked at.
*
* A rail fixes both. The actions become one click and one glyph each, and because a glyph is
* ~28px the whole control costs a strip 40px wide down an edge nobody reads — so it can sit
* vertically CENTRED, where a pointer finds it without a trip to a corner, and still obscure
* less than the old button did. The vocabulary that used to live under "Conventions" is already
* on screen in `GraphLegend`, which is where a key belongs.
*
* ── Icon-only means the label is in the TOOLTIP, not on screen ──
*
* Which in turn means every button carries an `aria-label`: a rail of three unlabelled glyphs
* is unusable to a screen reader and unreadable to anyone who has not met it before. The
* tooltip is the sighted reader's version of exactly that string — one label, two renderings,
* so they cannot drift.
*
* The provider is LOCAL. The app mounts one in `app/(routes)/layout.tsx`, but a graph also
* renders inside surfaces that do not descend from it (the home rail's artifact panel, the
* company explorer), and a tooltip with no provider silently never opens. Nesting providers is
* free; discovering months later that a rail has no labels is not.
*
* ── Where the schema lives now ──
*
* Third glyph, one popover, a real form: `GraphSchemaEditor`. It is here rather than in a
* document-level menu because every knob it owns is about how THIS canvas is drawn, and the
* canvas is what you are looking at while you turn them.
*/
import { useState } from 'react';
import { LayoutGrid, Plus, Settings2 } from 'lucide-react';
import { FieldPopover } from '@/components/ui/field';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { GraphSchemaEditor } from './GraphSchemaEditor';
import type { GraphNodeField, GraphSchema, GraphView } from '@zero/server/graph';
export interface GraphSidebarProps {
schema: GraphSchema;
/** Open the add-node flow. Absent when the viewer cannot write. */
onAddNode?: () => void;
/** Re-run the layout and persist it. Absent on a read-only surface. */
onAutoFormat?: () => void;
/**
* The two schema writes, which travel TOGETHER: `onSetView` for the view knobs,
* `onSetFields` for the card face. The Schema glyph appears only when both are supplied,
* because an editor that can repoint the colour field but not tick a field onto the node's
* face is a form with a dead half — worse than no form, since nothing on screen says which
* half is dead.
*/
onSetView?: (view: Partial<GraphView>) => void;
onSetFields?: (fields: GraphNodeField[]) => void;
/** The rail's placement on the canvas belongs to whoever mounts the `<Panel>`. */
className?: string;
}
/**
* One rail button.
*
* `size-7` around a `size-4` glyph — the padding is INSIDE the button, so the hover fill has
* 6px of air around the mark instead of clamping to it, and the circle reads as a target rather
* than as a highlighted icon. A circle, not a rounded square, and no border at rest: the hover
* fill IS the affordance (crystallized.md § 6), which is why `hover:bg-hover` is not optional.
* `aria-expanded:bg-hover` keeps the Schema glyph lit while its panel is open, so the panel
* reads as belonging to something.
*/
const RAIL_BUTTON = cn(
'flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full',
'text-muted-foreground transition-colors hover:bg-hover hover:text-foreground',
'aria-expanded:bg-hover aria-expanded:text-foreground',
);
export function GraphSidebar({
schema,
onAddNode,
onAutoFormat,
onSetView,
onSetFields,
className,
}: GraphSidebarProps) {
const [schemaOpen, setSchemaOpen] = useState(false);
const canEditSchema = !!onSetView && !!onSetFields;
// Every item is conditional, so the rail can end up with nothing in it — on a read-only
// graph, all three are absent. An empty raised strip floating on the canvas is a control
// that does nothing, which is worse than no control.
if (!onAddNode && !onAutoFormat && !canEditSchema) return null;
return (
<TooltipProvider delayDuration={300}>
<div
// A toolbar rather than a bare div: the three glyphs are one group of controls, and
// `aria-orientation` is what tells a screen reader the arrow keys run down, not across.
role="toolbar"
aria-orientation="vertical"
aria-label="Graph actions"
className={cn(
'flex flex-col gap-0.5 rounded-xl border border-surface-border bg-raised p-1.5 shadow-xs',
className,
)}
>
{onAddNode && (
<RailButton
label="Add node"
icon={<Plus aria-hidden className="size-4" />}
onClick={onAddNode}
/>
)}
{onAutoFormat && (
<RailButton
label="Auto-format"
icon={<LayoutGrid aria-hidden className="size-4" />}
onClick={onAutoFormat}
/>
)}
{/* Both, not `canEditSchema`: the pair is what narrows them to functions here. */}
{onSetView && onSetFields && (
// The tooltip wraps the POPOVER, not the other way round: `FieldPopover` owns its
// `PopoverTrigger asChild`, so the only place a `TooltipTrigger` can sit is inside
// the trigger it is given. Both are Radix slots, so the two compose onto one button.
<Tooltip>
<FieldPopover
open={schemaOpen}
onOpenChange={setSchemaOpen}
// The rail is a vertical strip on the canvas's left edge, so the panel opens to
// its RIGHT and grows downward from the glyph. Radix's default `bottom` would
// drop it over the drawing, and flip it upward whenever the rail sits low.
side="right"
align="start"
trigger={
<TooltipTrigger asChild>
<button type="button" aria-label="Schema" className={RAIL_BUTTON}>
<Settings2 aria-hidden className="size-4" />
</button>
</TooltipTrigger>
}
>
<GraphSchemaEditor
schema={schema}
onSetView={onSetView}
onSetFields={onSetFields}
/>
</FieldPopover>
<TooltipContent side="right">Schema</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}
function RailButton({
label,
icon,
onClick,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label={label} onClick={onClick} className={RAIL_BUTTON}>
{icon}
</button>
</TooltipTrigger>
{/* Right, always: a tooltip on the other side of a left-edge rail opens off the canvas. */}
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}