@k2b/ui
Selection controls
Single, multi, and compact controlled selection.
Select filters its static options only when searchable is set; MultiSelectInput renders its search field by default. An option color replaces the icon with a dot in Select, and tints the icon and selected pill in MultiSelectInput. In a narrow trigger, pills that do not fit collapse into a +N summary. SelectChip is the compact form: a 10rem menu with a trailing check marker.
Type in the search field to filter the four static options.
TSX
Copy<Select label="Team" description="Type to filter the static options." value={team} options={options} onValueChange={setTeam} searchable clearable /> {/* MultiSelectInput always renders its search field */}<MultiSelectInput label="Teams" value={teams} onValueChange={setTeams} options={options} clearable /> {/* Pills that do not fit collapse into a +N summary */}<div style="width:10rem"> <MultiSelectInput label="Teams (narrow)" value={teams} onValueChange={setTeams} options={options} clearable /></div> <Select label="Team (no search)" value={team} onValueChange={setTeam} options={options} /> <SelectChip aria-label="Range" value={range()} onValueChange={setRange} icon="ti ti-calendar" options={rangeOptions} />Select and MultiSelectInput can narrow one option list with the same optional group chips. Options may belong to several groups; text search stays inside the active group, and All removes the group filter.
Search the active group or switch groups to narrow the catalogue.
TSX
Copy<Select label="Icon" value={icon} onValueChange={setIcon} options={options} groups={[ { value: "recommended", label: "Recommended" }, { value: "food", label: "Food" }, { value: "work", label: "Work" }, ]} defaultGroup="recommended" searchable/>; <MultiSelectInput label="Icons" value={icons} onValueChange={setIcons} options={options} groups={groups} defaultGroup="recommended"/>An optional, local layout switch keeps the same filtered options and selection semantics while moving between detailed rows and compact tiles. The tile size is a bounded density choice, not consumer-owned CSS.
Use the icon beside the groups to switch between detailed rows and compact tiles.
TSX
Copy<Select label="Icon" value={icon} onValueChange={setIcon} options={options} groups={groups} defaultGroup="recommended" viewToggle defaultView="grid" gridSize="md" searchable/>Select, MultiSelectInput, and SelectChip cover three different selection
tasks. The parent owns every selected value.
Use select inputs
Choose from the state transition, not from the desired visual shape:
| Task | Component | Value contract |
|---|---|---|
| Choose one value in a form | Select |
one controlled value or null |
| Type, validate, and keep one remote value | AutocompleteSelect |
one controlled ID or null |
| Choose several values | MultiSelectInput |
one controlled ID array |
| Choose one compact toolbar value | SelectChip |
one controlled value |
| Filter a result set | FilterChip |
controlled filter state |
| Find an item, perform an action, then clear | Combobox |
selected item callback |
| Run a secondary action or open a link | Dropdown |
no field value |
Do not rebuild select rows in a Dropdown. That loses the shared field,
listbox, or radio semantics and makes alignment and keyboard behavior the
consumer's responsibility. Dropdown therefore accepts only declarative
actions and choices. See Dropdown and ContextMenu for
action menus.
Import
import {
MultiSelectInput,
type MultiSelectInputProps,
type MultiSelectOption,
Select,
SelectChip,
} from "@k2b/ui";Select
Pass the selected value directly or through a Solid accessor. Selection and
clearing are atomic actions, so both onValueChange and onValueCommit
receive the complete next value. Clearing emits null.
Static options may be strings, { id, label?, description?, icon?, color? },
or normalized { value, label, description?, icon?, color?, disabled?, groups? }
objects.
Select.fetchData(query, signal, group) and
MultiSelectInput.fetchData(query, signal, group) accept their convenient
source shapes. Their loadOptions(query, signal, group) variants accept
normalized options. They run with
an empty query when the dropdown opens, debounce later input, and abort stale
requests. group is the active group value or null for all options. Pass
selectedOption when the current value needs display metadata before the first
result arrives, or selectedOptions for MultiSelectInput.
Remote sources always show the search field. Set searchable to add it to a
static option list, where it filters labels, descriptions, and values in the
browser. In Select, an option color replaces the icon with a color dot on the
trigger and in the list. MultiSelectInput tints the option icon and the
selected pill with it instead.
Filter one list by group
Pass groups to Select or MultiSelectInput when a long option list benefits
from a short set of filters.
Each option lists its group values through option.groups; one option may
belong to several groups. defaultGroup selects the initial group. The built-in
All choice removes the group filter, and allGroupLabel can rename it.
For static options, the active group and text search are combined. For remote
options, apply the third group argument in the data source and return the
matching page. The component does not filter a partial remote result page in
the browser.
<Select
label="Icon"
value={icon()}
onValueChange={setIcon}
options={[
{
value: "ti ti-briefcase",
label: "Briefcase",
groups: ["recommended", "work"],
},
{
value: "ti ti-coffee",
label: "Coffee",
groups: ["recommended", "food"],
},
]}
groups={[
{ value: "recommended", label: "Recommended" },
{ value: "food", label: "Food" },
{ value: "work", label: "Work" },
]}
defaultGroup="recommended"
searchable
/>;Switch between list and tile views
Set viewToggle to offer list and tile views for visual options.
defaultView accepts "list" or "grid" and defaults to "list".
gridSize accepts "sm", "md", or "lg" and defaults to "md":
| Size | Tile content | Minimum tile width |
|---|---|---|
sm |
icon or color plus label | 4.5rem |
md |
icon, label, and description | 7rem |
lg |
roomier label and description | 9rem |
The switch changes presentation only. Search, groups, remote loading,
disabled options, keyboard focus, and selection continue to use the same
option list. The chosen view stays local to the mounted Select; remounting
starts from defaultView. Without viewToggle, Select always keeps the
list layout.
<Select
label="Icon"
value={icon()}
onValueChange={setIcon}
options={icons}
groups={iconGroups}
defaultGroup="recommended"
viewToggle
defaultView="grid"
gridSize="md"
searchable
/>;MultiSelectInput
Pass the selected array directly or through a Solid accessor. Every selection
change reports the complete next array through both onValueChange and
onValueCommit. Static options may carry a color for selected pills.
It supports the same static or remote option sources. For remote data, selectedOptions supplies labels and metadata for selected IDs that are not in the current result page.
The dropdown always opens with a search field, which filters a static option
list in the browser. Pass searchable={false} for a short fixed list.
Show selections in a narrow trigger
The trigger keeps one row at field height. It shows as many whole pills as fit,
in selection order, and collapses the rest into a +N summary pill. Screen
readers announce the summary as "N more selected" in the inherited locale, and
its tooltip lists the hidden labels. The first pill always stays; when even it
does not fit beside the summary, its label truncates with an ellipsis. Every
pill is at most 9rem wide and shows its full label as a tooltip. The dropdown
still lists every selected option, and Backspace in the open dropdown removes
the last selection, including a hidden one.
The trigger fills its container. A container sized by its content grows with
the pills; the field collapses them only while a maximum width or flex
shrinking holds that container narrower, and shows them again once it has
room. A flex item that wraps the field needs min-width: 0 to shrink below its
pills.
Show a value in a property row
Set appearance="plain" on Select or MultiSelectInput when the value is a
property of the item shown around it, such as the priority or labels in a
detail panel. The default appearance="field" keeps the boxed form control.
A plain trigger shows the value as text: no box, no chevron, the selected
option's color dot, and MultiSelectInput pills that wrap instead of
collapsing into +N. Hover, keyboard focus, and an open list paint a quiet
surface behind it; nothing moves between those states.
As the direct value of a DescriptionList row, the
plain control takes over the whole row: the label opens the list too, and the
surface covers the row. Elsewhere it covers only the control.
A plain trigger has no room for buttons beside its value:
- a
clearableSelectends its list with an entry named byplaceholder, such as No priority, which clears the value; MultiSelectInputpills have no remove button; deselect in the list;- the empty value shows
placeholderin muted text. When the options carry colors,Selectkeeps the dot column with an empty ring. PassplaceholderIcon, such asti ti-plus, for an additive empty state like + Label.
A Select whose parent keeps value at null works as an add action: the
trigger stays the placeholder and onValueChange reports each picked option.
<DescriptionList
layout="rows"
size="sm"
items={[
{
term: "Priority",
description: (
<Select
aria-label="Priority"
appearance="plain"
placeholder="No priority"
clearable
options={priorities}
value={priority}
onValueChange={setPriority}
/>
),
},
{
term: "Labels",
description: (
<MultiSelectInput
aria-label="Labels"
appearance="plain"
placeholder="Label"
placeholderIcon="ti ti-plus"
options={labels}
value={labelIds}
onValueChange={setLabelIds}
/>
),
},
]}
/>;Show read-only values as plain text in the same row instead of a disabled plain control. A disabled plain control stays readable and only stops reacting.
SelectChip
SelectChip accepts its current value directly or through a Solid accessor.
Each selection closes the menu before reporting through both value callbacks,
so a consumer update cannot leave stale option content visible. Its options use
{ value, label }, and their values may be strings or numbers. Optional
icon, image, and description metadata supports compact rich choices such
as model or environment selectors. The menu sizes to its options, at least
10rem, and long options wrap at the viewport; menuWidth sets an exact width
instead.
Set loading while a selection saves. The trigger stays the same size, is
disabled and marked aria-busy="true", and shows a spinner in place of its
chevron. The icon-only form uses the button's centered busy overlay. With
reduced motion, the spinner stands still. Report the result or an error where
the page reports results; the busy state does not announce the outcome.
A disabled button loses keyboard focus. When a chip that had focus is
disabled, by disabled or loading, focus returns to it once it is enabled
again, unless focus moved somewhere else in the meantime.
Selection popovers dismiss synchronously. Do not add a generic host-level
[popover] display or overlay transition around them; @k2b/ui explicitly
keeps interactive choice surfaces immediate.
Keep it for compact toolbars. It supports the same field label, description, reactive error, required, and disabled state, but has no clear state.
Set iconOnly together with an icon and "aria-label" when the surrounding
toolbar or section header already explains the control's context. The trigger
then uses the shared icon-button treatment while the menu keeps the same radio
selection contract. Use size only with this icon-only form.
const [permission, setPermission] = createSignal("read");
<SelectChip
aria-label="Permission"
value={permission}
onValueChange={setPermission}
options={[
{ value: "read", label: "View", icon: "ti ti-eye" },
{ value: "write", label: "Edit", icon: "ti ti-pencil" },
{ value: "admin", label: "Manage", icon: "ti ti-shield" },
]}
/>;
<SelectChip
aria-label="Sort notes"
icon="ti ti-arrows-sort"
iconOnly
size="xs"
value={sort()}
onValueChange={setSort}
options={sortOptions}
/>;API reference
See shared field props for FieldProps, ValueFieldProps<T> and MaybeAccessor<T>.
type ChoiceOption<T extends string = string> = {
value: T; label: string; description?: string; icon?: string; color?: string; disabled?: boolean;
groups?: readonly string[];
};
type SelectOption = ChoiceOption<string>;
type SelectSourceOption =
| string
| {
id: string;
label?: string;
description?: string;
icon?: string;
color?: string;
groups?: readonly string[];
}
| SelectOption;
type SelectGroup = {
value: string; label: string;
};
type SelectView = "list" | "grid";
type SelectGridSize = "sm" | "md" | "lg";
type ChoiceAppearance = "field" | "plain";
Select props
type SelectProps = ValueFieldProps<string | null> & {
placeholder?: string; icon?: string; activeIcon?: string; options?: SelectSourceOption[];
fetchData?: (query: string, signal: AbortSignal, group: string | null) => Promise<SelectSourceOption[]>;
loadOptions?: (query: string, signal: AbortSignal, group: string | null) => Promise<readonly ChoiceOption<string>[]>;
selectedOption?: ChoiceOption<string>; selectedLabel?: () => string | undefined; fetchDebounceMs?: number;
debounceMs?: number; searchable?: boolean;
filterOptions?: (options: readonly SelectOption[], query: string) => readonly SelectOption[];
groups?: readonly SelectGroup[]; defaultGroup?: string; groupsAriaLabel?: string; allGroupLabel?: string;
viewToggle?: boolean; defaultView?: SelectView; gridSize?: SelectGridSize; searchPlaceholder?: string;
clearable?: boolean; name?: string; appearance?: ChoiceAppearance; placeholderIcon?: string;
};
MultiSelectInput props
type MultiSelectOption =
| string
| { id: string; label?: string; description?: string; icon?: string; color?: string; groups?: readonly string[] }
| ChoiceOption<string>;
type MultiSelectFetchDataFn = (query: string, signal: AbortSignal, group: string | null) => Promise<MultiSelectOption[]>;
type MultiSelectInputProps = ValueFieldProps<string[]> & {
options?: MultiSelectOption[]; fetchData?: MultiSelectFetchDataFn;
selectedOptions?: () => MultiSelectOption[]; placeholder?: string; icon?: string; activeIcon?: string;
fetchDebounceMs?: number; debounceMs?: number;
loadOptions?: (query: string, signal: AbortSignal, group: string | null) => Promise<readonly ChoiceOption<string>[]>;
groups?: readonly SelectGroup[]; defaultGroup?: string; groupsAriaLabel?: string; allGroupLabel?: string;
searchable?: boolean; clearable?: boolean; name?: string;
renderOption?: (option: ChoiceOption<string>) => JSX.Element;
renderValue?: (option: ChoiceOption<string>) => JSX.Element; searchPlaceholder?: string;
loadingLabel?: string; noResultsLabel?: string; emptyLabel?: string; retryLabel?: string;
clearLabel?: string; appearance?: ChoiceAppearance; placeholderIcon?: string;
};
SelectChip props
type SelectChipOption<T extends string | number = string> = {
value: T; label: string; description?: string; icon?: string; image?: string; disabled?: boolean;
};
type SelectChipProps<T extends string | number = string> = ValueFieldProps<T> & {
value: MaybeAccessor<T>; options: SelectChipOption<T>[]; icon?: string; iconOnly?: boolean;
size?: ButtonSize; placeholder?: string; position?: DropdownPosition; menuWidth?: string; name?: string; loading?: boolean;
open?: boolean; onOpenChange?: (open: boolean) => void;
};fetchData takes precedence over loadOptions; either remote source takes precedence over options. Debounce is fetchDebounceMs ?? debounceMs ?? 200 milliseconds. Static Select search defaults off; static MultiSelectInput search defaults on. Remote search is always enabled. Render callbacks belong to MultiSelectInput, not Select. They receive normalized ChoiceOption<string> objects and return Solid content. appearance defaults to "field"; DatePicker, DateTimePicker, DateRangePicker, and NumberInput accept the same ChoiceAppearance.
Use open with onOpenChange to control the SelectChip menu, including opening
it from another control and reacting to Escape or outside close. Leave open
undefined for the existing uncontrolled menu behavior.
SelectChip.size uses ButtonSize; position uses DropdownPosition. menuWidth is an optional exact CSS length; without it the menu sizes to its options, at least 10rem. SelectChip does not accept null; represent an explicit empty choice with an option of your value type.
Accessibility
Use visible labels on Select and MultiSelectInput. When the surrounding layout already shows the field name, such as a description list term, pass "aria-label" instead; the trigger and the options popover both use it. Their triggers expose combobox, listbox, expanded, selected, required, disabled, description, and error state. Select groups use a labelled radio group; arrow keys, Home, and End change the active group. The optional layout button names the view it will open and does not change the listbox semantics.
On touch devices, each option in the Select and MultiSelectInput list is
at least 44 px (2.75rem) tall. Options sit close together, so the option
itself grows instead of reaching into its neighbour; devices without a touch
screen keep the compact options, and grid tiles are already larger.
SelectChip uses menu items, which follow the same rule.
The option list of Select, MultiSelectInput, AutocompleteSelect, and
Combobox keeps the trigger's width, so it lines up with its field. A long
option label or description wraps there instead of ending in an ellipsis;
grid tiles keep their two-line clamp.
Option labels must remain clear without icons or colors. If the surrounding
toolbar already names a SelectChip, use the native "aria-label" property
instead of repeating a visible label.
A plain trigger keeps the same combobox semantics and its accessible name. Its focus ring is drawn on the row surface. The clear entry is a normal option, selected while the value is empty, so arrow keys reach it.
Runtime
Triggers and selected values render in server HTML. Dropdown positioning, keyboard navigation, remote loading, selection, and clearing require browser code. MultiSelectInput measures its pills in the browser, so server HTML shows every pill until browser measurements collapse those that do not fit.
Example
const [status, setStatus] = createSignal("open");
<Select
label="Status"
placeholder="Choose a status"
options={[
{ id: "open", label: "Open", icon: "ti ti-circle" },
{ id: "done", label: "Done", icon: "ti ti-check" },
]}
value={status}
onValueChange={setStatus}
clearable
/>;