Components
ComboBox
Multi-select picker with chip-rendered selections inside the field and a checkbox dropdown panel. Type to filter, ↑↓ to navigate, Enter to toggle, Backspace removes the last chip.
Preview#
Tags
design
<ForenComboBox
label="Tags"
placeholder="Add tags…"
options={[
{ value: "design", label: "Design", description: "Visual + tokens" },
{ value: "code", label: "Code", description: "Component implementation" },
{ value: "spec", label: "Spec", description: "Acceptance criteria" },
]}
defaultValue={["design"]}
/>Installation#
Same package as the rest of Foren.
bash
npm install @foren/uitsx
import { ForenComboBox } from "@foren/ui";Usage#
Controlled or uncontrolled — value is a string[] of selected values.
tsx
// Uncontrolled
<ForenComboBox
options={options}
defaultValue={["a"]}
placeholder="Pick options…"
/>
// Controlled
const [v, setV] = useState<string[]>([]);
<ForenComboBox
options={options}
value={v}
onChange={setV}
/>Source#
Drop-in source if you'd rather skip the package.
components/ui/foren-combobox.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ForenCheckbox } from "./foren-checkbox";
export type ForenComboBoxOption = {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
};
export interface ForenComboBoxProps {
/** Static option list. Use `onSearch` for async filtering. */
options?: ForenComboBoxOption[];
/** Async filter — returns options for the given query. */
onSearch?: (query: string) => ForenComboBoxOption[] | Promise<ForenComboBoxOption[]>;
/** Controlled selected values. */
value?: string[];
/** Uncontrolled initial selection. */
defaultValue?: string[];
onChange?: (next: string[]) => void;
/** Optional label above field. */
label?: React.ReactNode;
/** Helper text below. */
description?: React.ReactNode;
/** Error message (also flips border red). */
error?: React.ReactNode;
/** Placeholder when nothing selected. */
placeholder?: string;
size?: "md" | "lg";
disabled?: boolean;
/** Cap selections. Defaults to unlimited. */
maxSelected?: number;
/** Render empty state when filter returns no matches. */
emptyState?: React.ReactNode;
/** Hide built-in search input inside the dropdown. */
hideSearch?: boolean;
className?: string;
}
const CaretIcon = ({ open }: { open: boolean }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("transition-transform duration-150", open && "rotate-180")}
>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const ForenComboBox = React.forwardRef<HTMLDivElement, ForenComboBoxProps>(
function ForenComboBox(
{
options: staticOptions,
onSearch,
value: controlled,
defaultValue = [],
onChange,
label,
description,
error,
placeholder = "Select…",
size = "md",
disabled = false,
maxSelected,
emptyState = "No matches.",
hideSearch = false,
className,
},
ref
) {
const isControlled = controlled !== undefined;
const [internal, setInternal] = React.useState<string[]>(defaultValue);
const value = isControlled ? controlled! : internal;
const [open, setOpenState] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const [entered, setEntered] = React.useState(false);
const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setOpen = React.useCallback((next: boolean | ((v: boolean) => boolean)) => {
setOpenState((prev) => {
const target = typeof next === "function" ? next(prev) : next;
if (prev && !target) {
setClosing(true);
if (closeTimer.current) clearTimeout(closeTimer.current);
const ms =
parseFloat(
typeof window !== "undefined"
? getComputedStyle(document.documentElement).getPropertyValue("--dropdown-close-dur")
: ""
) || 150;
closeTimer.current = setTimeout(() => setClosing(false), ms);
} else if (!prev && target) {
setClosing(false);
if (closeTimer.current) clearTimeout(closeTimer.current);
}
return target;
});
}, []);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const [asyncResults, setAsyncResults] = React.useState<ForenComboBoxOption[] | null>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const reactId = React.useId();
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
// Async filter
React.useEffect(() => {
if (!onSearch) return;
let cancelled = false;
(async () => {
const r = await onSearch(query);
if (!cancelled) setAsyncResults(r);
})();
return () => {
cancelled = true;
};
}, [query, onSearch]);
// All resolved options for the open panel.
const filtered = React.useMemo<ForenComboBoxOption[]>(() => {
if (onSearch) return asyncResults ?? [];
if (!staticOptions) return [];
const q = query.trim().toLowerCase();
if (!q) return staticOptions;
return staticOptions.filter((o) => {
const haystack =
(typeof o.label === "string" ? o.label : "") +
" " +
(typeof o.description === "string" ? o.description : "") +
" " +
o.value;
return haystack.toLowerCase().includes(q);
});
}, [staticOptions, asyncResults, onSearch, query]);
// For chip render — full option lookup table (last-known label).
const lookup = React.useRef<Map<string, ForenComboBoxOption>>(new Map());
React.useEffect(() => {
const src = staticOptions ?? asyncResults ?? [];
for (const o of src) lookup.current.set(o.value, o);
}, [staticOptions, asyncResults]);
React.useEffect(() => setActive(0), [filtered.length, open]);
// Apply .is-open one frame after mount so the transition runs from pre-open state.
React.useEffect(() => {
if (open) {
const r = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(r);
}
setEntered(false);
}, [open]);
// Outside click closes
React.useEffect(() => {
if (!open) return;
function onClick(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
window.addEventListener("mousedown", onClick);
return () => window.removeEventListener("mousedown", onClick);
}, [open]);
React.useEffect(() => {
if (open && !hideSearch) {
const t = setTimeout(() => searchRef.current?.focus(), 10);
return () => clearTimeout(t);
}
}, [open, hideSearch]);
function setValue(next: string[]) {
if (!isControlled) setInternal(next);
onChange?.(next);
}
function toggle(opt: ForenComboBoxOption) {
if (opt.disabled || disabled) return;
const has = value.includes(opt.value);
if (has) {
setValue(value.filter((v) => v !== opt.value));
} else {
if (maxSelected != null && value.length >= maxSelected) return;
setValue([...value, opt.value]);
}
}
function removeAt(v: string) {
setValue(value.filter((x) => x !== v));
}
function clearAll() {
setValue([]);
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
if (filtered[active]) {
e.preventDefault();
toggle(filtered[active]);
}
} else if (e.key === "Escape") {
setOpen(false);
} else if (e.key === "Backspace" && query.length === 0 && value.length > 0) {
e.preventDefault();
setValue(value.slice(0, -1));
}
}
const status: "default" | "error" = error ? "error" : "default";
const heightClass = size === "lg" ? "min-h-14" : "min-h-12";
const messageId = `${reactId}-msg`;
const message = error ?? description;
const messageColor = error
? "text-[var(--color-foren-error,#c62828)]"
: "text-[var(--color-foren-text-tertiary)]";
return (
<div ref={rootRef} className={cn("relative w-full flex flex-col gap-2", className)}>
{label && (
<span className="text-[14px] leading-[21px] font-medium tracking-[0.01em] text-[var(--color-foren-text-primary)]">
{label}
</span>
)}
<div
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-disabled={disabled || undefined}
aria-invalid={!!error || undefined}
aria-describedby={message ? messageId : undefined}
onClick={() => !disabled && setOpen((v) => !v)}
className={cn(
"flex flex-wrap items-center gap-1.5 rounded-[16px] border px-2.5 py-1.5 cursor-text transition-[background-color,border-color,box-shadow] duration-150 ease-out",
heightClass,
status === "default"
? "bg-[var(--color-foren-bg-subtle)] border-transparent hover:border-[var(--color-foren-border-default)] focus-within:bg-[var(--color-foren-bg-default)] focus-within:border-[var(--color-foren-border-focus)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-foren-border-focus)_18%,transparent)]"
: "bg-[var(--color-foren-bg-default)] border-[var(--color-foren-error,#c62828)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-foren-error,#c62828)_18%,transparent)]",
disabled && "bg-[var(--color-foren-bg-button-disabled)] border-transparent cursor-not-allowed pointer-events-none"
)}
>
{value.map((v) => {
const opt = lookup.current.get(v);
return (
<span
key={v}
className="inline-flex items-center gap-1 h-8 pl-3 pr-1.5 rounded-full bg-[var(--color-foren-bg-elevated)] border border-[var(--color-foren-border-muted)] text-[13px] leading-[18px] font-medium tracking-[0.01em] text-[var(--color-foren-text-primary)] shadow-[var(--shadow-foren-xs)]"
>
<span className="truncate max-w-[180px]">{opt?.label ?? v}</span>
<button
type="button"
aria-label={`Remove ${typeof opt?.label === "string" ? opt.label : v}`}
onClick={(e) => {
e.stopPropagation();
removeAt(v);
}}
className="inline-flex items-center justify-center size-5 rounded-full text-[var(--color-foren-text-tertiary)] hover:text-[var(--color-foren-text-primary)] hover:bg-[var(--color-foren-bg-subtle)] transition-colors cursor-pointer"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</span>
);
})}
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onKeyDown={onKeyDown}
onClick={(e) => e.stopPropagation()}
disabled={disabled}
placeholder={value.length === 0 ? placeholder : ""}
className="flex-1 min-w-[80px] bg-transparent outline-none font-medium tracking-[0.01em] text-[14px] leading-[21px] text-[var(--color-foren-text-primary)] placeholder:text-[var(--color-foren-text-disabled)] disabled:cursor-not-allowed"
/>
<span className="ml-auto shrink-0 text-[var(--color-foren-text-tertiary)] pointer-events-none">
<CaretIcon open={open} />
</span>
</div>
{(open || closing) && (
<div
role="listbox"
aria-multiselectable
data-origin="top-left"
className={cn(
"t-dropdown absolute z-50 top-[calc(100%+8px)] left-0 right-0 rounded-[16px] bg-[var(--color-foren-bg-elevated)] border border-[var(--color-foren-border-muted)] shadow-[var(--shadow-foren-md)] p-1.5 max-h-72 overflow-y-auto",
entered && !closing && "is-open",
closing && "is-closing"
)}
>
{value.length > 0 && (
<div className="flex items-center justify-between px-2 py-1.5 mb-1 border-b border-[var(--color-foren-border-muted)]">
<span className="text-[11px] font-mono text-[var(--color-foren-text-tertiary)]">
{value.length} selected{maxSelected ? ` / ${maxSelected}` : ""}
</span>
<button
type="button"
onClick={clearAll}
className="text-[11px] font-medium text-[var(--color-foren-text-secondary)] hover:text-[var(--color-foren-text-primary)] underline-offset-[3px] hover:underline cursor-pointer"
>
Clear
</button>
</div>
)}
{filtered.length === 0 && (
<div className="px-3 py-3 text-[12.5px] text-[var(--color-foren-text-tertiary)] text-center">
{emptyState}
</div>
)}
{filtered.map((opt, i) => {
const isActive = i === active;
const checked = value.includes(opt.value);
const atCap = !checked && maxSelected != null && value.length >= maxSelected;
return (
<div
key={opt.value}
role="option"
aria-selected={checked}
aria-disabled={opt.disabled || atCap || undefined}
onMouseEnter={() => setActive(i)}
className={cn(
"rounded-md transition-colors",
isActive ? "bg-[var(--color-foren-bg-subtle)]" : "hover:bg-[var(--color-foren-bg-sunken)]",
(opt.disabled || atCap) && "opacity-50"
)}
>
<ForenCheckbox
size="sm"
checked={checked}
disabled={opt.disabled || atCap}
onCheckedChange={() => toggle(opt)}
label={opt.label}
description={opt.description}
tabIndex={-1}
className="w-full px-2.5 py-2"
/>
</div>
);
})}
</div>
)}
{message && (
<p id={messageId} className={cn("text-[12px] leading-[16px] tracking-[0.01em]", messageColor)}>
{message}
</p>
)}
</div>
);
}
);
Filter while typing#
Type inside the field to live-filter the option list.
Roles
<ForenComboBox
label="Roles"
placeholder="Search roles…"
options={roles}
/>Max selected#
Cap selections with maxSelected — extra options disable when reached.
Pick up to 2
<ForenComboBox
label="Pick up to 2"
placeholder="Choose roles"
maxSelected={2}
options={[…]}
/>Async results#
Pass onSearch(query) → options to wire a remote API.
Markets
<ForenComboBox
label="Markets"
placeholder="Search countries…"
onSearch={async (q) => api.fuzzy(q)}
/>Error#
Pass an error message — flips border red, sets aria-invalid.
Skills
Select at least one option.
<ForenComboBox
label="Skills"
placeholder="Pick at least one"
options={options}
error="Select at least one option."
/>Interactive#
Type to filter, click a row or press Enter to toggle, dismiss × on a chip to remove.
Tags
designcode
Type to filter. Backspace removes the last chip.
value: [design, code]
const [v, setV] = useState<string[]>(["design", "code"]);
<ForenComboBox
label="Tags"
placeholder="Add tags…"
options={tags}
value={v}
onChange={setV}
/>API#
PropTypeDefault
optionsForenComboBoxOption[]
onSearch(q) => options | Promise<options>
valuestring[] (controlled)
defaultValuestring[]
onChange(next: string[]) => void
labelReactNode
descriptionReactNode
errorReactNode
placeholderstring
size"md" | "lg"
maxSelectednumber
emptyStateReactNode
disabledboolean