'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/cn';
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, ChevronDown, ChevronRight, ChevronUp, Loader2, Puzzle, Search, X } from '@/components/icons';
import {
isFloatingDropdownSupported,
openFloatingDropdown,
updateFloatingDropdown,
closeFloatingDropdown,
registerFloatingDropdownListener,
} from './floating-dropdown-bridge';
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 KitComboboxOption {
value: string;
label: string;
disabled?: boolean;
}
export interface KitComboboxProps {
options?: KitComboboxOption[];
/** Currently selected value (string for single, string[] for multiple). */
value?: string | string[] | null;
/** Called with the selected value (string for single, string[] for multiple). */
onChange?: (value: any) => void;
placeholder?: string;
disabled?: boolean;
/** Stretch to the width of its container (forms); settings rows keep it narrow. */
block?: boolean;
/** Allow selecting multiple options (e.g. for tags). */
multiple?: boolean;
/** Whether the user can clear the selected value (default: true). */
clearable?: boolean;
id?: string;
/** Accessible label when not wrapped by Field. */
ariaLabel?: string;
className?: string;
}
/** A searchable combobox with keyboard navigation, clear button and multiselect support. */
export function Combobox({
options = [],
value,
onChange,
placeholder,
disabled = false,
block = false,
multiple = false,
clearable = true,
id,
ariaLabel,
className,
}: KitComboboxProps) {
const autoId = React.useId();
const inputId = id ?? autoId;
const listboxId = `${inputId}-listbox`;
const containerRef = React.useRef(null);
const inputRef = React.useRef(null);
const listRef = React.useRef(null);
const optionRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [isOpen, setIsOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const [isTyping, setIsTyping] = React.useState(false);
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const selectedValues = React.useMemo(() => {
if (multiple) {
return Array.isArray(value) ? value : value ? [value] : [];
}
return [];
}, [multiple, value]);
const singleValue = React.useMemo(() => {
if (multiple) return '';
return typeof value === 'string' ? value : '';
}, [multiple, value]);
const selectedOption = React.useMemo(() => {
if (multiple || !singleValue) return null;
return options.find((o) => o.value === singleValue) || null;
}, [multiple, singleValue, options]);
const selectedOptions = React.useMemo(() => {
if (!multiple) return [];
return options.filter((o) => selectedValues.includes(o.value));
}, [multiple, options, selectedValues]);
// Synchronize input query with selected option label when not typing in single-select mode
React.useEffect(() => {
if (!isTyping) {
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
}, [selectedOption, isTyping, multiple]);
const filteredOptions = React.useMemo(() => {
if (!isTyping || !query.trim()) return options;
const q = query.trim().toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, isTyping, query]);
// When opening dropdown, pre-highlight the currently selected item or first item
React.useEffect(() => {
if (isOpen) {
if (multiple) {
setHighlightedIndex(filteredOptions.length > 0 ? 0 : -1);
} else {
const idx = filteredOptions.findIndex((o) => o.value === singleValue);
setHighlightedIndex(idx >= 0 ? idx : filteredOptions.length > 0 ? 0 : -1);
}
} else {
setHighlightedIndex(-1);
}
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-scroll highlighted option into view
React.useEffect(() => {
if (isOpen && highlightedIndex >= 0 && optionRefs.current[highlightedIndex]) {
const el = optionRefs.current[highlightedIndex];
if (typeof el?.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'nearest' });
}
}
}, [isOpen, highlightedIndex]);
// Close when clicking outside
React.useEffect(() => {
if (!isOpen) return;
const onDocMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
};
document.addEventListener('mousedown', onDocMouseDown);
return () => document.removeEventListener('mousedown', onDocMouseDown);
}, [isOpen, selectedOption, multiple]);
const toggleMultipleOption = (opt: KitComboboxOption) => {
if (opt.disabled) return;
const exists = selectedValues.includes(opt.value);
const next = exists ? selectedValues.filter((v) => v !== opt.value) : [...selectedValues, opt.value];
onChange?.(next);
setQuery('');
setIsTyping(false);
if (isOpen) {
inputRef.current?.focus();
}
};
const selectOption = (opt: KitComboboxOption) => {
if (opt.disabled) return;
if (multiple) {
toggleMultipleOption(opt);
} else {
onChange?.(opt.value);
setIsOpen(false);
setIsTyping(false);
setQuery(opt.value ? opt.label : '');
inputRef.current?.focus();
}
};
const isHostFloating = isFloatingDropdownSupported();
const getContainerRect = () => {
if (!containerRef.current) return null;
const r = containerRef.current.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
bottom: Math.round(r.bottom),
width: Math.round(r.width),
height: Math.round(r.height),
};
};
const selectOptionRef = React.useRef(selectOption);
selectOptionRef.current = selectOption;
const selectedOptionRef = React.useRef(selectedOption);
selectedOptionRef.current = selectedOption;
// When isOpen changes: if host floating dropdown is active, open it on host and register listener
React.useEffect(() => {
if (!isHostFloating) return;
if (isOpen) {
const rect = getContainerRect();
if (!rect) return;
openFloatingDropdown({
id: listboxId,
rect,
options: filteredOptions,
multiple,
selectedValues,
singleValue,
highlightedIndex,
ariaLabel: ariaLabel ?? placeholder,
});
const unregister = registerFloatingDropdownListener(listboxId, (ev) => {
if (ev.type === 'select') {
selectOptionRef.current(ev.option);
} else if (ev.type === 'highlight') {
setHighlightedIndex(ev.index);
} else if (ev.type === 'dismiss') {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOptionRef.current ? selectedOptionRef.current.label : '');
}
}
});
return () => {
unregister();
closeFloatingDropdown(listboxId);
};
}
}, [isOpen, isHostFloating, listboxId]); // eslint-disable-line react-hooks/exhaustive-deps
// Push updates to host dropdown when options, selection, or highlight changes while open
React.useEffect(() => {
if (!isHostFloating || !isOpen) return;
const rect = getContainerRect();
updateFloatingDropdown({
id: listboxId,
...(rect ? { rect } : {}),
options: filteredOptions,
multiple,
selectedValues,
singleValue,
highlightedIndex,
});
}, [isHostFloating, isOpen, filteredOptions, selectedValues, singleValue, highlightedIndex, multiple, listboxId]);
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (multiple) {
onChange?.([]);
} else {
onChange?.('');
}
setQuery('');
setIsTyping(false);
setIsOpen(false);
};
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (isOpen) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
} else {
setIsOpen(true);
setIsTyping(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setIsTyping(false);
} else if (filteredOptions.length > 0) {
setHighlightedIndex((prev) => (prev + 1 < filteredOptions.length ? prev + 1 : 0));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setIsTyping(false);
} else if (filteredOptions.length > 0) {
setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : filteredOptions.length - 1));
}
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
selectOption(filteredOptions[highlightedIndex]);
}
} else if (e.key === 'Escape') {
if (isOpen) {
e.preventDefault();
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
} else if (e.key === 'Tab') {
if (isOpen) {
setIsOpen(false);
setIsTyping(false);
if (multiple) {
setQuery('');
} else {
setQuery(selectedOption ? selectedOption.label : '');
}
}
} else if (e.key === 'Backspace' && multiple && query === '' && selectedValues.length > 0) {
const next = selectedValues.slice(0, -1);
onChange?.(next);
}
};
const hasValue = multiple ? selectedValues.length > 0 : Boolean(singleValue);
return (