'use client';
// The plugin UI kit: the app's own controls, for plugin iframes. Plugins get
// it as `require('@plugin-host/ui')`; the runtime hands over this module, so
// a plugin bundle carries none of it and always matches the app it runs in.
//
// Every component is built from the same Tailwind classes as its
// counterpart in the app (named in each comment), and
// ui-kit.test.tsx checks that those classes are still the app's. The CSS
// comes from app/(sandbox)/plugin-kit.css, which scans this folder only, so
// classes must be written out in full here (no string building).
//
// Each component's root carries `bk`, which scopes the CSS reset to kit
// elements: a plugin's own markup keeps the browser defaults it was written
// against.
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button as AppButton } from '@/components/ui/button';
import { Input as AppInput } from '@/components/ui/input';
import { getFileIconByName, getFolderIcon } from '@/components/files/file-icons';
import { iconForName } from '@/components/icons/named';
import { Check, ChevronRight, Loader2, Puzzle, Search, X } from '@/components/icons';
type Children = { children?: React.ReactNode };
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'primary';
// ─── Icons ─────────────────────────────────────────────────────
/** An app icon by name (the names api.ui.setActions takes). Unknown names show a puzzle piece. */
export function Icon({ name, size = 'md', className }: { name?: string; size?: 'xs' | 'sm' | 'md' | 'lg'; className?: string }) {
const Glyph = (name ? iconForName(name) : null) ?? Puzzle;
const sizeClass = { xs: 'w-3 h-3', sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-5 h-5' }[size];
return ;
}
/** The Files app's icon for a file name (by extension), or its folder icon. */
export function FileIcon({ name = '', folder = false, size = 'sm' }: { name?: string; folder?: boolean; size?: 'sm' | 'md' | 'lg' }) {
return {folder ? getFolderIcon(size) : getFileIconByName(name, size)};
}
/** Loader2 with animate-spin, as everywhere in the app. */
export function Spinner({ size = 'md', label }: { size?: 'sm' | 'md' | 'lg'; label?: string }) {
const sizeClass = { sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-6 h-6' }[size];
return (
{label ? {label} : …}
);
}
/** A spinner in the middle of a page or panel (plugin-page-app's loading state). */
export function Loading({ label }: { label?: string }) {
return (
);
}
// ─── Buttons ───────────────────────────────────────────────────
export interface KitButtonProps extends Omit, 'children'> {
/** primary = the app's default button, secondary = outline. */
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'icon';
icon?: string;
/** Shows a spinner and disables the button. */
busy?: boolean;
children?: React.ReactNode;
}
const BUTTON_VARIANT = { primary: 'default', secondary: 'outline', ghost: 'ghost', destructive: 'destructive' } as const;
/** components/ui/button.tsx itself. */
export const Button = React.forwardRef(function Button(
{ variant = 'primary', size = 'md', icon, busy = false, disabled, className, children, type = 'button', ...rest },
ref,
) {
return (
{busy ? : icon ? : null}
{children}
);
});
/** A text link styled like the app's inline links ("Add condition"). */
export function LinkButton({ icon, children, className, ...rest }: React.ButtonHTMLAttributes & { icon?: string }) {
return (
);
}
/** An external link: opens in a new tab. */
export function Link({ href, children, className }: { href: string; className?: string } & Children) {
return (
{children}
);
}
// ─── Form controls ─────────────────────────────────────────────
/** components/ui/input.tsx itself. */
export const Input = React.forwardRef>(function Input({ className, ...rest }, ref) {
return ;
});
/** The textarea of the identity form (Input without the fixed height). */
export const Textarea = React.forwardRef>(function Textarea({ className, ...rest }, ref) {
return (
);
});
export interface KitSelectProps extends Omit, 'onChange'> {
options?: { value: string; label: string; disabled?: boolean }[];
/** Called with the new value. */
onChange?: (value: string, event: React.ChangeEvent) => void;
/** Stretch to the width of its container (forms); settings rows keep it narrow. */
block?: boolean;
}
/** The settings select (settings-section.tsx). */
export function Select({ options, children, onChange, disabled, block = false, className, ...rest }: KitSelectProps) {
return (
);
}
export interface KitCheckboxProps {
checked: boolean;
onChange?: (checked: boolean) => void;
label?: React.ReactNode;
/** Name for screen readers when there is no visible label. */
ariaLabel?: string;
hint?: React.ReactNode;
disabled?: boolean;
id?: string;
}
/** The composer's check items and the Files app's select box: a drawn box, not the browser's. */
export function Checkbox({ checked, onChange, label, hint, disabled = false, id, ariaLabel }: KitCheckboxProps) {
const autoId = React.useId();
const boxId = id ?? autoId;
const box = (
);
if (!label) return box;
return (