103 lines
3.2 KiB
TypeScript
103 lines
3.2 KiB
TypeScript
import { useMemo, useState, type ReactNode } from "react";
|
|
import { cn } from "./cn.js";
|
|
|
|
export interface InspectorSectionSpec {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
group?: string;
|
|
content: ReactNode;
|
|
disabled?: boolean;
|
|
tone?: "default" | "accent";
|
|
}
|
|
|
|
export interface InspectorProps {
|
|
sections: InspectorSectionSpec[];
|
|
defaultOpen?: string[];
|
|
activeId?: string;
|
|
singleOpen?: boolean;
|
|
className?: string;
|
|
onActiveChange?: (id: string) => void;
|
|
}
|
|
|
|
export function Inspector({
|
|
sections,
|
|
defaultOpen = [],
|
|
activeId,
|
|
singleOpen = false,
|
|
className,
|
|
onActiveChange,
|
|
}: InspectorProps) {
|
|
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
|
|
const groups = useMemo(() => {
|
|
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
|
|
sections.forEach((section) => {
|
|
const previous = result[result.length - 1];
|
|
if (previous && previous.label === section.group) {
|
|
previous.sections.push(section);
|
|
} else {
|
|
result.push({ label: section.group, sections: [section] });
|
|
}
|
|
});
|
|
return result;
|
|
}, [sections]);
|
|
|
|
const toggle = (id: string) => {
|
|
setOpenIds((current) => {
|
|
const next = singleOpen ? new Set<string>() : new Set(current);
|
|
if (current.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
onActiveChange?.(id);
|
|
};
|
|
|
|
return (
|
|
<div className={cn("nodedc-inspector", className)}>
|
|
{groups.map((group, groupIndex) => (
|
|
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
|
{group.sections.map((section) => {
|
|
const isOpen = openIds.has(section.id);
|
|
return (
|
|
<section className="nodedc-inspector__section" key={section.id}>
|
|
<button
|
|
type="button"
|
|
className="nodedc-inspector__section-trigger"
|
|
data-open={isOpen ? "true" : undefined}
|
|
data-active={activeId === section.id ? "true" : undefined}
|
|
data-tone={section.tone === "accent" ? "accent" : undefined}
|
|
aria-expanded={isOpen}
|
|
disabled={section.disabled}
|
|
onClick={() => toggle(section.id)}
|
|
>
|
|
<span className="nodedc-inspector__section-label">{section.label}</span>
|
|
{section.description ? (
|
|
<span className="nodedc-inspector__section-description">{section.description}</span>
|
|
) : null}
|
|
</button>
|
|
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface ControlRowProps {
|
|
label: ReactNode;
|
|
children: ReactNode;
|
|
layout?: "inline" | "stack";
|
|
className?: string;
|
|
}
|
|
|
|
export function ControlRow({ label, children, layout = "inline", className }: ControlRowProps) {
|
|
return (
|
|
<div className={cn("nodedc-control-row", className)} data-layout={layout === "inline" ? undefined : layout}>
|
|
<div className="nodedc-control-row__label">{label}</div>
|
|
<div className="nodedc-control-row__control">{children}</div>
|
|
</div>
|
|
);
|
|
}
|