Skip to content
Panels

Panels

SidePanel

Inspector side panel with header, scrolling body, footer slot and field helpers.

npx bezel-add add side-panel

packages/ui/src/panels/SidePanel.tsx · 113 lines

import type { ReactNode } from 'react';
import { X } from 'lucide-react';

// @starting-style is paint-driven (not a mount effect + requestAnimationFrame),
// so the entrance still plays even if the tab was backgrounded when this
// panel mounted — rAF is paused on hidden tabs, @starting-style isn't.
const sidePanelStyle = `
  .yui-side-panel {
    transform: translateX(0);
    opacity: 1;
    transition: transform 200ms cubic-bezier(0.23,1,0.32,1), opacity 200ms cubic-bezier(0.23,1,0.32,1);
  }
  @starting-style {
    .yui-side-panel { transform: translateX(4%); opacity: 0; }
  }
`;

type SidePanelProps = {
  title: string;
  headerLeft?: ReactNode;
  onClose: () => void;
  footer?: ReactNode;
  children: ReactNode;
};

export function SidePanel({ title, headerLeft, onClose, footer, children }: SidePanelProps) {
  return (
    <div className="yui-side-panel w-72 bg-white border-l border-neutral-200 flex flex-col h-full">
      <style>{sidePanelStyle}</style>
      {/* Header */}
      <div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-neutral-100">
        <div className="flex items-center gap-2">
          {headerLeft}
          <span className="text-[10px] font-semibold text-[color:var(--bz-ink-subtle,#6b6b70)] uppercase tracking-wider">
            {title}
          </span>
        </div>
        <button
          onClick={onClose}
          aria-label="Close panel"
          className="p-1.5 rounded-lg hover:bg-neutral-100 text-[color:var(--bz-ink-subtle,#6b6b70)] transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#912c22]"
        >
          <X size={16} />
        </button>
      </div>

      {/* Body */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {children}
      </div>

      {/* Footer */}
      {footer && (
        <div className="p-4 border-t border-neutral-100">
          {footer}
        </div>
      )}
    </div>
  );
}

type FieldProps = {
  label: string;
  children: ReactNode;
};

export function PanelField({ label, children }: FieldProps) {
  return (
    <div>
      <label className="text-[10px] font-semibold text-[color:var(--bz-ink-subtle,#6b6b70)] uppercase tracking-wider">
        {label}
      </label>
      <div className="mt-1">{children}</div>
    </div>
  );
}

export function PanelInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
  return (
    <input
      {...props}
      className={
        'w-full px-3 py-2 text-sm bg-neutral-50 border border-[color:var(--bz-line-control,#8a8a8e)] rounded-xl ' +
        'focus:outline-none focus:ring-2 focus:ring-[color:var(--bz-focus-ring,#912c22)] placeholder:text-[color:var(--bz-ink-subtle,#6b6b70)] transition ' +
        (props.className ?? '')
      }
    />
  );
}

export function PanelTextarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
  return (
    <textarea
      {...props}
      className={
        'w-full px-3 py-2 text-sm bg-neutral-50 border border-[color:var(--bz-line-control,#8a8a8e)] rounded-xl ' +
        'focus:outline-none focus:ring-2 focus:ring-[color:var(--bz-focus-ring,#912c22)] resize-none transition ' +
        (props.className ?? '')
      }
    />
  );
}

export function PanelDeleteButton({ onClick, label = 'Delete' }: { onClick: () => void; label?: string }) {
  return (
    <button
      onClick={onClick}
      className="w-full flex items-center justify-center gap-2 py-2 text-sm text-[color:var(--bz-danger,#b91c1c)] hover:bg-red-50 rounded-xl transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#912c22]"
    >
      {label}
    </button>
  );
}