Panels
SidePanel
Inspector side panel with header, scrolling body, footer slot and field helpers.
npx bezel-add add side-panelpackages/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>
);
}