mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user