Combobox
Use combobox on a div element. It displays selected values as removable tags and an input field. The dropdown content is supplied by the caller — typically built with selectList and selectItem, which provide their own context-based state flow (context is a feature of those patches, not of combobox itself).
combobox gives the dropdown panel a default surface (background, "border-strong" outline, density-scaled radius, medium elevation() shadow) so it's usable without the caller styling content itself — selectList/selectItem (or any custom content) render on top of that surface.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
content | DomphyElement | — | Required. The floating popover element (e.g. a selectList). |
value | ValueOrState<string | number | Array<string | number | null | undefined> | null | undefined> | — | Selected value(s). |
options | Array<{ label: string; value: string }> | [] | Available options used to render selected-value tags. |
multiple | boolean | false | When true, the popover stays open after each selection. |
open | ValueOrState<boolean> | false | Controls whether the popover is open. |
placement | ValueOrState<Placement> | "bottom" | Floating popover placement relative to the host. |
color | ThemeColor | "neutral" | Color tone for the control surface and input. |
input | DomphyElement | — | Custom input element; when omitted a default <input> is created. |
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
type DomphyElement,
merge,
type PartialElement,
type StyleObject,
toState,
type ValueOrState,
} from "@domphy/core";
import type { Placement } from "@domphy/floating";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { createFloating } from "../utils/floating.js";
import { focusRing } from "../utils/focusRing.js";
import { tag } from "./tag.js";
/**
* A combobox/multi-select control: renders selected options as removable tags
* plus an input, and shows a floating popover (`content`) anchored to the host.
* Apply to a `<div>` element.
*
* @hostTag div
* @param props.multiple - Allow selecting multiple values (popover stays open on click). Optional `boolean`, default false.
* @param props.value - Selected value(s). Optional `ValueOrState<Array<number | string | null | undefined> | number | string | null | undefined>`, no default.
* @param props.options - Available `{ label, value }` options used to render selected tags. Optional `Array<{ label: string; value: string }>`, default `[]`.
* @param props.placement - Floating popover placement. Optional `ValueOrState<Placement>`, default "bottom".
* @param props.content - The floating popover content element. Required `DomphyElement`.
* @param props.color - Color tone for the control. Optional `ThemeColor`, default "neutral".
* @param props.open - Whether the popover is open. Optional `ValueOrState<boolean>`, default false.
* @param props.input - Custom input element; when omitted a default `<input>` is created. Optional `DomphyElement`.
* @example { div: null, $: [combobox({ options: [{ label: "A", value: "a" }], content: { div: null } })] }
*/
function combobox(props: {
multiple?: boolean;
value?: ValueOrState<
| Array<number | string | null | undefined>
| number
| string
| null
| undefined
>;
options?: Array<{ label: string; value: string }>;
placement?: ValueOrState<Placement>;
content: DomphyElement;
color?: ThemeColor;
open?: ValueOrState<boolean>;
input?: DomphyElement;
}): PartialElement {
const {
options = [],
placement = "bottom",
color = "neutral",
open = false,
multiple = false,
} = props;
const state = toState(props.value);
const openState = toState(open);
const { show, hide, anchorPartial } = createFloating({
kind: "combobox",
open: openState,
placement: toState(placement),
content: props.content,
});
const popoverPartial: PartialElement = {
onClick: (_e, node) => !multiple && hide(node),
dataTone: "shift-14",
style: {
backgroundColor: (listener) => themeColor(listener, "inherit"),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong")}`,
outlineOffset: "-1px",
boxShadow: elevation("medium"),
},
};
merge(props.content, popoverPartial);
const inputStyle: StyleObject = {
border: "none",
outline: "none",
padding: 0,
margin: 0,
flex: 1,
height: themeSpacing(6),
marginInlineStart: themeSpacing(2),
fontSize: (listener: any) => themeSize(listener, "inherit"),
color: (listener: any) => themeColor(listener, "text", color),
backgroundColor: (listener: any) => themeColor(listener, "inherit", color),
};
let inputElement: DomphyElement;
if (props.input) {
const inputPartial: PartialElement = {
onFocus: (_e, node) => show(node),
style: inputStyle,
_key: "combobox-input",
};
merge(props.input, inputPartial);
inputElement = props.input;
} else {
inputElement = {
input: null,
// Accessible name for the filter field (critical for axe label rule).
// Native text input (not role=combobox) — full combobox ARIA needs a
// stable aria-controls target id for the floating list; label is enough
// for WCAG name/role/value of the filter field.
ariaLabel: "Filter options",
onFocus: (_e, node) => show(node),
value: (listener: any) => {
state.get(listener);
return "";
},
style: inputStyle,
_key: "combobox-input",
};
}
const wrap: DomphyElement<"div"> = {
div: (listener) => {
const val = state.get(listener);
const vals = Array.isArray(val) ? val : [val];
const opts = options.filter((opt) => vals.includes(opt.value));
const items: DomphyElement[] = opts.map((opt) => {
return {
span: opt.label,
$: [tag({ color, removable: true })],
_key: opt.value,
_onRemove: (_node) => {
const cur = state.get();
const curVals = Array.isArray(cur) ? cur : [cur];
const filter = curVals.filter((v) => v !== opt.value);
multiple ? state.set(filter as any) : state.set(filter[0] as any);
},
};
});
items.push(inputElement);
return items;
},
style: {
display: "flex",
flexWrap: "wrap",
gap: themeSpacing(1),
},
};
const partial: PartialElement = {
_onInsert: (node) => {
if (node.tagName !== "div") {
console.warn(`"combobox" primitive patch must use div tag`);
}
},
_onInit: (node) => node.children.insert(wrap),
style: {
minWidth: themeSpacing(32),
outlineOffset: "-1px",
outline: (listener) =>
`1px solid ${themeColor(listener, "border-strong", "neutral")}`,
paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 1),
paddingInline: (listener) => themeSpacing(themeDensity(listener) * 1),
borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5),
fontSize: (listener) => themeSize(listener, "inherit"),
color: (listener) => themeColor(listener, "text", color),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
transition:
"background-color 140ms ease, outline-color 140ms ease, box-shadow 140ms ease",
"&:focus-within": {
boxShadow: (listener) => focusRing(listener, color),
},
},
};
merge(anchorPartial, partial);
return anchorPartial;
}
export { combobox };