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>
  );
}