diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index 0eda9b2ec..c571f9a93 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -7,13 +7,17 @@ // to a wrapper
. 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; } +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(null); const instanceRef = useRef(null); const [height, setHeight] = useState(0); const [width, setWidth] = useState(undefined); + const [floatingDropdown, setFloatingDropdown] = useState(null); const inline = isInlineSlot(slot); // null = pending, true/false = decided const [show, setShow] = useState(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 ( + <> +
+ {floatingDropdown && ( + { + 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(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(
+ 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 ? ( +
+ No results +
+ ) : ( + dropdown.options.map((opt, i) => { + const isSelected = dropdown.multiple + ? dropdown.selectedValues.includes(opt.value) + : opt.value === dropdown.singleValue; + const isHighlighted = dropdown.highlightedIndex === i; + return ( +
{ 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', + )} + > + {opt.label} + {isSelected &&
+ ); + }) + )} +
, + document.body, ); } diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index 0bd576e43..d8fffdfcc 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + + ); + 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(); + fireEvent.keyDown(input, { key: 'Backspace' }); + expect(onChange).toHaveBeenCalledWith(['apple']); + }); + + it('associates label when wrapped in Field', () => { + render(); + 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(); + 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(); + 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; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index c66da9000..deb34af95 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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); } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index fce78e31c..cd93a04f5 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -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 ─────────────────────────────────────────────── diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 16d0591c5..eae038586 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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 diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx index 7fa9f5ce4..dc9b20a8f 100644 --- a/lib/plugin-sandbox/ui-kit/components.tsx +++ b/lib/plugin-sandbox/ui-kit/components.tsx @@ -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(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 ( +
+
{ + 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) => ( + + {opt.label} + {!disabled && ( + + )} + + ))} + + = 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', + ), + )} + /> +
+ {clearable && hasValue && !disabled && ( + + )} + +
+
+ + {isOpen && !isHostFloating && ( +
+ {filteredOptions.length === 0 ? ( +
+ No results +
+ ) : ( + filteredOptions.map((opt, i) => { + const isSelected = multiple ? selectedValues.includes(opt.value) : opt.value === singleValue; + const isHighlighted = highlightedIndex === i; + return ( +
{ 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', + )} + > + {opt.label} + {isSelected &&
+ ); + }) + )} +
+ )} +
+ ); +} + export interface KitCheckboxProps { checked: boolean; onChange?: (checked: boolean) => void; diff --git a/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts b/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts new file mode 100644 index 000000000..ec93d1d76 --- /dev/null +++ b/lib/plugin-sandbox/ui-kit/floating-dropdown-bridge.ts @@ -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>(); + +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): void { + hostSender?.({ type: 'floating-dropdown-open', ...spec }); +} + +export function updateFloatingDropdown(spec: Omit): 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); + } + } + } +} diff --git a/lib/plugin-sandbox/ui-kit/index.ts b/lib/plugin-sandbox/ui-kit/index.ts index 7f27e58b8..18dc6492a 100644 --- a/lib/plugin-sandbox/ui-kit/index.ts +++ b/lib/plugin-sandbox/ui-kit/index.ts @@ -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,