WidgetFrame.tsx3.1 KBView on GitHub
'use client';

import type { ReactNode } from 'react';
import { Pencil } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';

/**
 * The chrome every rail widget wears: a titled card with an icon and an optional header action.
 *
 * One frame for every tile rather than each widget drawing its own card, because the rail's
 * whole job is to read as a single column of comparable things. A widget that brings its own
 * border, padding and title weight makes the rail look like a pile of unrelated panels — which
 * is exactly what the home screen looked like before it had a rail.
 *
 * NO PER-TILE REMOVE CONTROL. Removing is editing, and editing happens in one place: the "Edit
 * widgets" button at the foot of the rail. A hover-revealed ✕ on every card put a destructive
 * action under the cursor on the way to every other one, and split "what is on my rail" across
 * six places instead of one.
 */
export function WidgetFrame({
  title,
  icon: Icon,
  action,
  onEdit,
  editLabel,
  children,
  className,
  bodyClassName,
}: {
  title: string;
  icon: LucideIcon;
  /** A control shown at the right of the header (e.g. the day arrows, "Add agent"). */
  action?: ReactNode;
  /**
   * Opens this widget's own settings. When given, a pencil appears in the header ON HOVER —
   * so a per-widget control costs no header space until you go looking for it, instead of
   * sitting there stating its own configuration at you all day.
   */
  onEdit?: () => void;
  /** Accessible name for the pencil, e.g. "Pipeline filters". */
  editLabel?: string;
  children: ReactNode;
  className?: string;
  bodyClassName?: string;
}) {
  return (
    <section
      data-testid="widget-frame"
      data-widget-title={title}
      className={cn(
        'group/widget flex flex-col overflow-hidden rounded-xl border border-border bg-surface/75 shadow-sm',
        className,
      )}
    >
      <header className="flex shrink-0 items-center gap-2 px-3 pb-1.5 pt-2.5">
        <Icon aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
        <h3 className="truncate text-xs font-medium uppercase tracking-wide text-muted-foreground">
          {title}
        </h3>

        <div className="ml-auto flex shrink-0 items-center gap-1">
          {action}
          {onEdit && (
            <button
              type="button"
              onClick={onEdit}
              aria-label={editLabel ?? `Edit ${title}`}
              title={editLabel ?? `Edit ${title}`}
              // Revealed on hover of the WIDGET, not of the header row: reaching for it moves
              // the pointer up out of the body, and a control that vanishes on the way there
              // cannot be clicked.
              className="cursor-pointer rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-sunken hover:text-foreground focus-visible:opacity-100 group-hover/widget:opacity-100"
            >
              <Pencil aria-hidden className="h-3.5 w-3.5" />
            </button>
          )}
        </div>
      </header>

      <div className={cn('min-h-0 px-3 pb-3', bodyClassName)}>{children}</div>
    </section>
  );
}