Merge pull request #1251 from tobiasge/feat/plugin-ui-kit-combobox

feat(plugins): add Combobox component to UI kit with host floating dropdown portal
This commit is contained in:
Linus Rath
2026-10-18 14:34:26 +02:00
committed by GitHub
8 changed files with 1072 additions and 12 deletions
+213 -6
View File
@@ -7,13 +7,17 @@
// to a wrapper <div>.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
import type { FloatingDropdownOption } from '@/lib/plugin-sandbox/protocol';
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout';
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
import { useThemeStore } from '@/stores/theme-store';
import { Check } from '@/components/icons';
import { cn } from '@/lib/utils';
interface Props {
pluginId: string;
@@ -21,11 +25,23 @@ interface Props {
extraProps?: Record<string, unknown>;
}
interface FloatingDropdownState {
id: string;
rect: { left: number; top: number; bottom: number; width: number; height: number };
options: FloatingDropdownOption[];
multiple?: boolean;
selectedValues: string[];
singleValue?: string;
highlightedIndex: number;
ariaLabel?: string;
}
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const [height, setHeight] = useState<number>(0);
const [width, setWidth] = useState<number | undefined>(undefined);
const [floatingDropdown, setFloatingDropdown] = useState<FloatingDropdownState | null>(null);
const inline = isInlineSlot(slot);
// null = pending, true/false = decided
const [show, setShow] = useState<boolean | null>(null);
@@ -71,11 +87,41 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
setHeight(h);
if (w !== undefined) setWidth(w);
},
onFloatingDropdown: (msg) => {
if (msg.type === 'floating-dropdown-open') {
setFloatingDropdown({
id: msg.id,
rect: msg.rect,
options: msg.options,
multiple: msg.multiple,
selectedValues: msg.selectedValues,
singleValue: msg.singleValue,
highlightedIndex: msg.highlightedIndex,
ariaLabel: msg.ariaLabel,
});
} else if (msg.type === 'floating-dropdown-update') {
setFloatingDropdown((prev) => {
if (!prev || prev.id !== msg.id) return prev;
return {
...prev,
...(msg.rect ? { rect: msg.rect } : {}),
...(msg.options ? { options: msg.options } : {}),
...(msg.multiple !== undefined ? { multiple: msg.multiple } : {}),
...(msg.selectedValues ? { selectedValues: msg.selectedValues } : {}),
...(msg.singleValue !== undefined ? { singleValue: msg.singleValue } : {}),
...(msg.highlightedIndex !== undefined ? { highlightedIndex: msg.highlightedIndex } : {}),
};
});
} else if (msg.type === 'floating-dropdown-close') {
setFloatingDropdown((prev) => (prev?.id === msg.id ? null : prev));
}
},
});
instanceRef.current = inst;
return () => {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
setFloatingDropdown(null);
};
// We intentionally don't depend on props here - propagating prop
// changes happens via postMessage below to avoid iframe churn.
@@ -103,12 +149,173 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
if (show !== true) return null;
return (
<>
<div
ref={wrapperRef}
style={inline
? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden' }
: { height, minHeight: height }}
data-plugin-iframe-slot={`${pluginId}:${slot}`}
/>
{floatingDropdown && (
<FloatingDropdownPortal
dropdown={floatingDropdown}
wrapperEl={wrapperRef.current}
onSelect={(option) => {
instanceRef.current?.sendFloatingDropdownEvent({
type: 'floating-dropdown-select',
id: floatingDropdown.id,
option,
});
}}
onHighlight={(index) => {
instanceRef.current?.sendFloatingDropdownEvent({
type: 'floating-dropdown-highlight',
id: floatingDropdown.id,
index,
});
}}
onDismiss={() => {
instanceRef.current?.sendFloatingDropdownEvent({
type: 'floating-dropdown-dismiss',
id: floatingDropdown.id,
});
setFloatingDropdown(null);
}}
/>
)}
</>
);
}
function FloatingDropdownPortal({
dropdown,
wrapperEl,
onSelect,
onHighlight,
onDismiss,
}: {
dropdown: FloatingDropdownState;
wrapperEl: HTMLElement | null;
onSelect: (option: FloatingDropdownOption) => void;
onHighlight: (index: number) => void;
onDismiss: () => void;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
// Compute screen coordinates relative to host viewport
const iframe = wrapperEl?.querySelector('iframe') ?? wrapperEl;
const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 };
const hostLeft = iframeRect.left + dropdown.rect.left;
const hostTop = iframeRect.top + dropdown.rect.top;
const hostBottom = iframeRect.top + dropdown.rect.bottom;
const hostWidth = dropdown.rect.width;
const spaceBelow = typeof window !== 'undefined' ? window.innerHeight - hostBottom - 8 : 300;
const spaceAbove = hostTop - 8;
const openUpward = spaceBelow < 200 && spaceAbove > spaceBelow;
const maxHeight = Math.max(120, Math.min(240, openUpward ? spaceAbove : spaceBelow));
const left = typeof window !== 'undefined' ? Math.max(8, Math.min(hostLeft, window.innerWidth - hostWidth - 8)) : hostLeft;
const top = openUpward ? undefined : hostBottom + 4;
const bottom = openUpward && typeof window !== 'undefined' ? window.innerHeight - hostTop + 4 : undefined;
// Auto-scroll highlighted option into view
useEffect(() => {
const i = dropdown.highlightedIndex;
if (i >= 0 && optionRefs.current[i]) {
optionRefs.current[i]?.scrollIntoView({ block: 'nearest' });
}
}, [dropdown.highlightedIndex]);
// Outside click and scroll handling
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
onDismiss();
}
};
const onScroll = (e: Event) => {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) {
return; // Allow scroll inside the dropdown listbox itself
}
onDismiss();
};
const onResize = () => onDismiss();
document.addEventListener('mousedown', onMouseDown, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
document.removeEventListener('mousedown', onMouseDown, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [onDismiss]);
if (typeof document === 'undefined') return null;
return createPortal(
<div
ref={wrapperRef}
style={inline
? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden' }
: { height, minHeight: height }}
data-plugin-iframe-slot={`${pluginId}:${slot}`}
/>
ref={dropdownRef}
id={dropdown.id}
role="listbox"
aria-multiselectable={dropdown.multiple || undefined}
aria-label={dropdown.ariaLabel}
style={{
position: 'fixed',
left: `${left}px`,
top: top !== undefined ? `${top}px` : undefined,
bottom: bottom !== undefined ? `${bottom}px` : undefined,
width: `${hostWidth}px`,
maxHeight: `${maxHeight}px`,
zIndex: 100010,
}}
className="overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-lg p-1 focus:outline-none animate-in fade-in-50 zoom-in-95 duration-100"
>
{dropdown.options.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground text-center">
No results
</div>
) : (
dropdown.options.map((opt, i) => {
const isSelected = dropdown.multiple
? dropdown.selectedValues.includes(opt.value)
: opt.value === dropdown.singleValue;
const isHighlighted = dropdown.highlightedIndex === i;
return (
<div
key={opt.value}
ref={(el) => { optionRefs.current[i] = el; }}
id={`${dropdown.id}-opt-${i}`}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onMouseDown={(e) => {
// Prevent stealing focus from the input in the iframe
e.preventDefault();
}}
onClick={() => {
if (opt.disabled) return;
onSelect(opt);
}}
onMouseEnter={() => {
onHighlight(i);
}}
className={cn(
'relative flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors',
isHighlighted ? 'bg-accent text-accent-foreground' : 'text-foreground',
isSelected && 'font-medium',
opt.disabled && 'pointer-events-none opacity-50',
)}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check className="w-4 h-4 ms-2 shrink-0 text-primary" aria-hidden="true" />}
</div>
);
})
)}
</div>,
document.body,
);
}
+178 -1
View File
@@ -152,7 +152,7 @@ describe('plugin UI kit components', () => {
const { makePluginRequire } = await import('../plugin-sandbox/runtime');
const req = makePluginRequire(null);
expect(req('@plugin-host/ui')).toBe(kit.pluginUiKit);
expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip']));
expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Combobox', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip']));
expect(() => req('left-pad')).toThrow(/not available/);
});
});
@@ -224,6 +224,183 @@ describe('plugin dialog footer', () => {
});
});
describe('Combobox', () => {
const options = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
];
it('renders with placeholder and closed dropdown', () => {
render(<kit.Combobox options={options} placeholder="Choose fruit" />);
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('placeholder', 'Choose fruit');
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('opens and filters options on input', () => {
render(<kit.Combobox options={options} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'ban' } });
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('listbox')).toBeTruthy();
expect(screen.getByText('Banana')).toBeTruthy();
expect(screen.queryByText('Apple')).toBeNull();
});
it('selects option on click', () => {
const onChange = vi.fn();
render(<kit.Combobox options={options} onChange={onChange} />);
const input = screen.getByRole('combobox');
fireEvent.click(input);
fireEvent.click(screen.getByText('Cherry'));
expect(onChange).toHaveBeenCalledWith('cherry');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('navigates with keyboard and selects on Enter', () => {
const onChange = vi.fn();
render(<kit.Combobox options={options} onChange={onChange} />);
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'ArrowDown' }); // opens dropdown
expect(screen.getByRole('listbox')).toBeTruthy();
fireEvent.keyDown(input, { key: 'ArrowDown' }); // highlight next
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalled();
});
it('clears selection when clear button is clicked without opening dropdown', () => {
const onChange = vi.fn();
render(<kit.Combobox options={options} value="banana" onChange={onChange} />);
const clearBtn = screen.getByLabelText('Clear');
fireEvent.click(clearBtn);
expect(onChange).toHaveBeenCalledWith('');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('closes dropdown and clears selection if clear button is clicked while open', () => {
const onChange = vi.fn();
render(<kit.Combobox options={options} value="banana" onChange={onChange} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
expect(screen.getByRole('listbox')).toBeTruthy();
const clearBtn = screen.getByLabelText('Clear');
fireEvent.click(clearBtn);
expect(onChange).toHaveBeenCalledWith('');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('supports multiselect mode with chip rendering, toggling and backspace removal', () => {
const onChange = vi.fn();
const { rerender } = render(
<kit.Combobox options={options} multiple value={['apple']} onChange={onChange} />
);
expect(screen.getByRole('checkbox', { name: 'Apple' })).toBeTruthy();
expect(screen.getByLabelText('Remove Apple')).toBeTruthy();
// Clicking remove chip does not open dropdown
fireEvent.click(screen.getByLabelText('Remove Apple'));
expect(onChange).toHaveBeenCalledWith([]);
expect(screen.queryByRole('listbox')).toBeNull();
// Open dropdown and toggle cherry
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.click(screen.getByText('Cherry'));
expect(onChange).toHaveBeenCalledWith(['apple', 'cherry']);
// Backspace on empty input removes last value
rerender(<kit.Combobox options={options} multiple value={['apple', 'cherry']} onChange={onChange} />);
fireEvent.keyDown(input, { key: 'Backspace' });
expect(onChange).toHaveBeenCalledWith(['apple']);
});
it('associates label when wrapped in Field', () => {
render(<kit.Field label="Fruit"><kit.Combobox options={options} /></kit.Field>);
expect(screen.getByLabelText('Fruit')).toBeInstanceOf(HTMLInputElement);
});
});
describe('Combobox floating dropdown bridge', () => {
const options = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];
it('delegates dropdown rendering to the host when supported and handles host selection', async () => {
const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge');
const sentMessages: any[] = [];
bridge.initFloatingDropdownBridge(
(msg) => { sentMessages.push(msg); },
() => true,
);
const onChange = vi.fn();
render(<kit.Combobox options={options} onChange={onChange} id="fruit-picker" />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
// Dropdown listbox is NOT rendered inside the component root
expect(screen.queryByRole('listbox')).toBeNull();
// Verify open message was sent to host
expect(sentMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'floating-dropdown-open',
id: 'fruit-picker-listbox',
options,
}),
]),
);
// Host selects banana
act(() => {
bridge.dispatchFloatingDropdownEvent('fruit-picker-listbox', {
type: 'select',
option: options[1],
});
});
expect(onChange).toHaveBeenCalledWith('banana');
// Tear down bridge for following tests
bridge.initFloatingDropdownBridge(null as any, () => false);
});
it('supports multiselect toggling and updates host with selected values', async () => {
const bridge = await import('../plugin-sandbox/ui-kit/floating-dropdown-bridge');
const sentMessages: any[] = [];
bridge.initFloatingDropdownBridge(
(msg) => { sentMessages.push(msg); },
() => true,
);
const onChange = vi.fn();
render(<kit.Combobox options={options} multiple value={['apple']} onChange={onChange} id="tag-picker" />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
expect(screen.queryByRole('listbox')).toBeNull();
// Host selects banana
act(() => {
bridge.dispatchFloatingDropdownEvent('tag-picker-listbox', {
type: 'select',
option: options[1],
});
});
expect(onChange).toHaveBeenCalledWith(['apple', 'banana']);
// Tear down bridge
bridge.initFloatingDropdownBridge(null as any, () => false);
});
});
describe('plugin-kit.css', () => {
it('builds the kit\'s utilities without the global Tailwind reset', async () => {
const postcss = (await import('postcss')).default;
+20 -1
View File
@@ -16,8 +16,12 @@ import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg,
FloatingDropdownSelectMsg, FloatingDropdownHighlightMsg, FloatingDropdownDismissMsg,
} from './protocol';
export type FloatingDropdownHostMsg = FloatingDropdownOpenMsg | FloatingDropdownUpdateMsg | FloatingDropdownCloseMsg;
// ─── Callback marshalling ────────────────────────────────────
/**
@@ -75,6 +79,8 @@ export interface SlotOptions {
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content size (width only for inline slots). */
onResize: (height: number, width?: number) => void;
/** Called whenever the sandbox requests to open/update/close a floating dropdown. */
onFloatingDropdown?: (msg: FloatingDropdownHostMsg) => void;
}
export interface InitDoneInfo {
@@ -172,6 +178,7 @@ export class SandboxInstance {
initPayload: InitPayload,
hostContainer: HTMLElement,
private slotResizeCb: ((height: number, width?: number) => void) | null,
private slotFloatingDropdownCb?: ((msg: FloatingDropdownHostMsg) => void) | null,
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
@@ -364,6 +371,13 @@ export class SandboxInstance {
this.slotResizeCb?.(height, width ?? undefined);
return;
}
case 'floating-dropdown-open':
case 'floating-dropdown-update':
case 'floating-dropdown-close': {
this.slotFloatingDropdownCb?.(msg);
return;
}
}
}
@@ -456,6 +470,11 @@ export class SandboxInstance {
this.send({ type: 'props-update', props: encoded });
}
sendFloatingDropdownEvent(msg: FloatingDropdownSelectMsg | FloatingDropdownHighlightMsg | FloatingDropdownDismissMsg): void {
if (this.destroyed) return;
this.send(msg);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
@@ -544,5 +563,5 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
locale: opts.locale,
theme: snapshotHostTheme(),
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
}
+67 -2
View File
@@ -145,6 +145,48 @@ export interface SlotShouldShowResultMsg {
show: boolean;
}
export interface FloatingDropdownRect {
left: number;
top: number;
bottom: number;
width: number;
height: number;
}
export interface FloatingDropdownOption {
value: string;
label: string;
disabled?: boolean;
}
export interface FloatingDropdownOpenMsg {
type: 'floating-dropdown-open';
id: string;
rect: FloatingDropdownRect;
options: FloatingDropdownOption[];
multiple?: boolean;
selectedValues: string[];
singleValue?: string;
highlightedIndex: number;
ariaLabel?: string;
}
export interface FloatingDropdownUpdateMsg {
type: 'floating-dropdown-update';
id: string;
rect?: FloatingDropdownRect;
options?: FloatingDropdownOption[];
multiple?: boolean;
selectedValues?: string[];
singleValue?: string;
highlightedIndex?: number;
}
export interface FloatingDropdownCloseMsg {
type: 'floating-dropdown-close';
id: string;
}
export type SandboxToHost =
| ReadyMsg
| InitDoneMsg
@@ -153,7 +195,10 @@ export type SandboxToHost =
| CallbackInvokeMsg
| HookResultMsg
| SlotResizeMsg
| SlotShouldShowResultMsg;
| SlotShouldShowResultMsg
| FloatingDropdownOpenMsg
| FloatingDropdownUpdateMsg
| FloatingDropdownCloseMsg;
// ─── Host → Sandbox messages ─────────────────────────────────
@@ -198,6 +243,23 @@ export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: st
/** Host → sandbox: upload progress of an api.proxy.fetch request. */
export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; }
export interface FloatingDropdownSelectMsg {
type: 'floating-dropdown-select';
id: string;
option: FloatingDropdownOption;
}
export interface FloatingDropdownHighlightMsg {
type: 'floating-dropdown-highlight';
id: string;
index: number;
}
export interface FloatingDropdownDismissMsg {
type: 'floating-dropdown-dismiss';
id: string;
}
export type HostToSandbox =
| InitMsg
| StreamChunkMsg
@@ -210,7 +272,10 @@ export type HostToSandbox =
| ThemeChangeMsg
| SettingsChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg;
| SlotShouldShowMsg
| FloatingDropdownSelectMsg
| FloatingDropdownHighlightMsg
| FloatingDropdownDismissMsg;
// ─── Constants ───────────────────────────────────────────────
+17
View File
@@ -29,6 +29,7 @@ import type {
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-kit/floating-dropdown-bridge';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
@@ -1148,6 +1149,18 @@ function handleHostMessage(ev: MessageEvent): void {
case 'stream-progress':
try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ }
break;
case 'floating-dropdown-select':
dispatchFloatingDropdownEvent(msg.id, { type: 'select', option: msg.option });
break;
case 'floating-dropdown-highlight':
dispatchFloatingDropdownEvent(msg.id, { type: 'highlight', index: msg.index });
break;
case 'floating-dropdown-dismiss':
dispatchFloatingDropdownEvent(msg.id, { type: 'dismiss' });
break;
}
}
@@ -1161,6 +1174,10 @@ export function SandboxRuntime(): React.JSX.Element {
if (!host) return;
parentWindow = host.window;
parentOrigin = host.origin;
initFloatingDropdownBridge(
(msg) => sendToHost(msg),
() => !!parentWindow,
);
window.addEventListener('message', handleHostMessage);
// Initial ping, targeted at the host origin so it can only land on the
// page that framed us. Guard at module scope so React strict mode's
+494 -1
View File
@@ -20,7 +20,14 @@ 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';
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';
@@ -160,6 +167,492 @@ export function Select({ options, children, onChange, disabled, block = false, c
);
}
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<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div
ref={containerRef}
className={cn('bk relative', block ? 'w-full' : 'w-56', className)}
>
<div
onClick={() => {
if (!disabled) inputRef.current?.focus();
}}
className={cn(
'relative w-full rounded-md border border-input bg-background transition-colors duration-150 focus-within:ring-2 focus-within:ring-ring hover:border-muted-foreground',
disabled && 'cursor-not-allowed opacity-50',
multiple ? 'flex flex-wrap items-center gap-1.5 p-1.5 min-h-[38px] pe-14' : 'flex items-center',
)}
>
{multiple &&
selectedOptions.map((opt) => (
<span
key={opt.value}
role="checkbox"
aria-checked="true"
aria-label={opt.label}
className="bk inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs text-foreground font-medium max-w-full"
>
<span className="truncate">{opt.label}</span>
{!disabled && (
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleMultipleOption(opt);
}}
aria-label={`Remove ${opt.label}`}
className="text-muted-foreground hover:text-foreground p-0.5 rounded focus:outline-none"
>
<X className="w-3 h-3" aria-hidden="true" />
</button>
)}
</span>
))}
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
autoComplete="off"
disabled={disabled}
aria-expanded={isOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-label={ariaLabel}
aria-activedescendant={isOpen && highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined}
value={query}
placeholder={multiple && selectedValues.length > 0 ? '' : placeholder}
onChange={(e) => {
setQuery(e.target.value);
setIsTyping(true);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => {
if (!disabled && !isOpen) {
setIsOpen(true);
setIsTyping(false);
}
}}
onClick={() => {
if (!disabled && !isOpen) {
setIsOpen(true);
setIsTyping(false);
}
}}
onKeyDown={handleKeyDown}
className={cn(
multiple
? 'flex-1 min-w-[80px] bg-transparent border-0 p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-0'
: cn(
'w-full rounded-md border-0 bg-transparent ps-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground outline-none',
clearable && hasValue ? 'pe-14' : 'pe-8',
),
)}
/>
<div className="absolute end-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-muted-foreground pointer-events-auto">
{clearable && hasValue && !disabled && (
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={handleClear}
aria-label="Clear"
className="p-1 rounded hover:text-foreground focus:outline-none"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
<button
type="button"
tabIndex={-1}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={handleToggle}
aria-label={isOpen ? 'Close' : 'Open'}
className="p-1 rounded hover:text-foreground focus:outline-none disabled:opacity-50"
>
{isOpen ? <ChevronUp className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />}
</button>
</div>
</div>
{isOpen && !isHostFloating && (
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-multiselectable={multiple || undefined}
aria-label={ariaLabel ?? placeholder}
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md p-1 focus:outline-none"
>
{filteredOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground text-center">
No results
</div>
) : (
filteredOptions.map((opt, i) => {
const isSelected = multiple ? selectedValues.includes(opt.value) : opt.value === singleValue;
const isHighlighted = highlightedIndex === i;
return (
<div
key={opt.value}
ref={(el) => { optionRefs.current[i] = el; }}
id={`${listboxId}-opt-${i}`}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectOption(opt)}
onMouseEnter={() => setHighlightedIndex(i)}
className={cn(
'relative flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors',
isHighlighted ? 'bg-accent text-accent-foreground' : 'text-foreground',
isSelected && 'font-medium',
opt.disabled && 'pointer-events-none opacity-50',
)}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check className="w-4 h-4 ms-2 shrink-0 text-primary" aria-hidden="true" />}
</div>
);
})
)}
</div>
)}
</div>
);
}
export interface KitCheckboxProps {
checked: boolean;
onChange?: (checked: boolean) => void;
@@ -0,0 +1,82 @@
// Bridge between Combobox in `@plugin-host/ui` and the sandbox runtime.
// When running inside a host-framed sandbox iframe, Combobox delegates its
// floating dropdown rendering to the host so the dropdown menu floats
// outside the popup modal boundary without causing the popup to scroll.
import type {
FloatingDropdownOpenMsg,
FloatingDropdownUpdateMsg,
FloatingDropdownOption,
FloatingDropdownRect,
} from '../protocol';
export type { FloatingDropdownOption, FloatingDropdownRect };
export type FloatingDropdownHostEvent =
| { type: 'select'; option: FloatingDropdownOption }
| { type: 'highlight'; index: number }
| { type: 'dismiss' };
type DropdownListener = (event: FloatingDropdownHostEvent) => void;
let hostSender: ((msg: any) => void) | null = null;
let hostAvailableCheck: (() => boolean) | null = null;
const listeners = new Map<string, Set<DropdownListener>>();
export function initFloatingDropdownBridge(
sender: (msg: any) => void,
isAvailable: () => boolean,
): void {
hostSender = sender;
hostAvailableCheck = isAvailable;
}
export function isFloatingDropdownSupported(): boolean {
return hostAvailableCheck ? hostAvailableCheck() : false;
}
export function openFloatingDropdown(spec: Omit<FloatingDropdownOpenMsg, 'type'>): void {
hostSender?.({ type: 'floating-dropdown-open', ...spec });
}
export function updateFloatingDropdown(spec: Omit<FloatingDropdownUpdateMsg, 'type'>): void {
hostSender?.({ type: 'floating-dropdown-update', ...spec });
}
export function closeFloatingDropdown(id: string): void {
hostSender?.({ type: 'floating-dropdown-close', id });
}
export function registerFloatingDropdownListener(
id: string,
listener: DropdownListener,
): () => void {
let set = listeners.get(id);
if (!set) {
set = new Set();
listeners.set(id, set);
}
set.add(listener);
return () => {
set?.delete(listener);
if (set && set.size === 0) {
listeners.delete(id);
}
};
}
export function dispatchFloatingDropdownEvent(
id: string,
event: FloatingDropdownHostEvent,
): void {
const set = listeners.get(id);
if (set) {
for (const listener of set) {
try {
listener(event);
} catch (err) {
console.error('[floating-dropdown] listener error', err);
}
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export { DialogButtons, SlotPropsContext, useSlotProps } from './dialog-buttons'
export type { DialogButtonSpec } from './dialog-buttons';
/** Bumped when a component is added or its props change in a way plugins can see. */
export const KIT_VERSION = 1;
export const KIT_VERSION = 2;
export const pluginUiKit = Object.freeze({
...components,