Combobox
Search a bounded registered choice set while keeping query text, committed option identity and applied results distinct.
Choose registered identity or free text
Combobox combines editable search with a registered choice. Its public value is the option’s stable ID; its input text is a label/query. Use Autocomplete when arbitrary text is the submitted value, Select when search is unnecessary, or Radio when comparison benefits from continuously visible choices.
A highlighted option, last committed ID, current search text and loaded application result are separate states. Do not treat typing a plausible label as proof that a record was chosen or permission granted. The wrapper filters caller-provided choices through the pinned primitive; it performs no remote lookup or generative inference.
Anatomy and specifications
| Part | Actual contract |
|---|---|
| Input | Native input/ref with combobox and virtual-focus relationships. Minimum 44px height, full width, 12px inline padding plus 52px reserved at the trigger edge. |
| Trigger | Actual button, 44px target, decorative 16px chevron and localizable accessible name. Logical inline-end positioning follows explicit DOM direction. |
| Root / field registration | Installed single-selection root has no visible DOM. It owns selection/query interaction and named hidden ID serialization; the visible input is not a second named field. |
| Popup / list | Portalled anchor positioning with 8px offset/collision padding, viewport width bound and maximum 320px/available height. One border surface, bounded scrolling and no decorative wide shadow. |
| Option / selected / highlighted | Registered div options with at least 44px targets, 8px × 12px padding, selected check/bold text and independent 2px highlight outline. Disabled choices reject activation. |
| Empty and direction | Localizable no-match copy preserves a search/recovery path. The polite empty region has zero padding when empty. Explicit dir reaches the inline wrapper and portalled popup. |
| Focus / forced colors | Input and trigger have separate focus cues. Forced colors retain current/highlight/boundary information; state color transitions are absent. |
Actual IDs, queries and recoverable results
Choose an available local destination and submit. Native FormData contains its registered ID, rather than the displayed label. The lab validates the current option’s availability and that the draft text corresponds to the chosen label before requesting the actual local preview. Explicit completion changes real content; failure/cancellation retain the draft and loaded preview.
The second instance has separate state and generated relationships. External restoration sets a chosen ID and controlled query together; clearing truly clears both. Disabled/read-only modes prevent editing, with an independent restore control. All fixtures are English-only page memory, without remote routing, persistence, authenticated access or source authority.
Committed ID: none. Query: (empty). 0 actual suggestion activations.
Loaded registered destination preview
Actual English-only workspace overview content.
Actual native form entries: No native form submission.
Loaded overview remains until an explicit local completion.
'use client';
import {useRef,useState,type CSSProperties} from 'react';
import {Button,Combobox} from '@mobtranslate/ui';
const actionStyle:CSSProperties={minWidth:0,maxWidth:'100%',minHeight:44,height:'auto',whiteSpace:'normal',overflowWrap:'anywhere'};
const options=[{value:'overview',label:'Local workspace overview',text:'Actual English-only workspace overview content.'},{value:'notes',label:'Local reading notes',text:'Actual English-only reading-note content.'},{value:'saved',label:'Local saved examples',text:'Actual English-only saved-example content.'},{value:'restricted',label:'Reviewed source unavailable',text:'Unavailable fixture',disabled:true}];
export function ComboboxChoiceExample(){
const [value,setValue]=useState('');
const [query,setQuery]=useState('');
const [other,setOther]=useState('');
const [count,setCount]=useState(0);
const [disabled,setDisabled]=useState(false);
const [readOnly,setReadOnly]=useState(false);
const [rtl,setRtl]=useState(false);
const [pending,setPending]=useState(false);
const [error,setError]=useState('');
const [requested,setRequested]=useState<string | null>(null);
const [loaded,setLoaded]=useState('overview');
const [payload,setPayload]=useState('No native form submission.');
const [status,setStatus]=useState('Loaded overview remains until an explicit local completion.');
const input=useRef<HTMLInputElement>(null);
const heading=useRef<HTMLHeadingElement>(null);
function outcome(result:'complete'|'fail'|'cancel'){
if(!pending || requested===null)return;
if(result==='complete'){setLoaded(requested);setStatus('Actual local destination preview updated. No remote save or route request occurred.');}
if(result==='fail')setStatus('Local destination choice failed. Selected ID, query and loaded preview remain; submit again to retry.');
if(result==='cancel')setStatus('Local destination choice cancelled. Selected ID, query and loaded preview remain.');
setPending(false);setRequested(null);heading.current?.focus();
}
return <div data-combobox-lab className="space-y-4">
<div className="flex flex-wrap gap-3"><Button style={actionStyle} type="button" variant="outline" disabled={pending} aria-pressed={disabled} onClick={()=>setDisabled(v=>!v)}>Disable local registered choices</Button><Button style={actionStyle} type="button" variant="outline" disabled={pending} aria-pressed={readOnly} onClick={()=>setReadOnly(v=>!v)}>Make local registered choices read only</Button><Button style={actionStyle} type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(v=>!v)}>Use RTL local registered choices</Button><Button style={actionStyle} type="button" variant="outline" disabled={pending} onClick={()=>{setValue('notes');setQuery('Local reading notes');setDisabled(false);setReadOnly(false);setError('');}}>Restore external registered choice</Button></div>
<form noValidate className="space-y-4" onSubmit={event=>{event.preventDefault();if(pending || disabled)return;const selected=options.find(option=>option.value===value && !option.disabled);if(!selected || input.current?.value!==selected.label){setError('Choose an available registered destination; search text alone is not its ID.');input.current?.focus();return;}const data=new FormData(event.currentTarget);setPayload(JSON.stringify([...data.entries()]));setRequested(String(data.get('destination')));setPending(true);setError('');setStatus('Registered destination requested; loaded content remains until an explicit outcome.');heading.current?.focus();}}>
<label htmlFor="combobox-local-target" className="block font-semibold">Registered local destination</label>
<Combobox ref={input} id="combobox-local-target" name="destination" required options={options} value={value} onChange={next=>{setValue(next);setError('');}} inputValue={query} onInputValueChange={setQuery} onOptionSelect={()=>setCount(v=>v+1)} disabled={disabled || pending} readOnly={readOnly} dir={rtl ? 'rtl' : 'ltr'} triggerLabel="Show local destination choices" aria-describedby={error ? 'combobox-target-help combobox-target-error' : 'combobox-target-help'} aria-invalid={!!error} placeholder="Search then choose a local destination" />
<p id="combobox-target-help">Search text filters choices. The registered field value is the last committed ID; this form rejects an unmatched draft query before requesting a preview.</p>
{error && <p id="combobox-target-error" role="alert">{error}</p>}
<label htmlFor="combobox-independent" className="block font-semibold">Independent optional registered choice</label>
<Combobox id="combobox-independent" name="otherDestination" options={options} value={other} onChange={setOther} disabled={disabled || pending} readOnly={readOnly} dir={rtl ? 'rtl' : 'ltr'} triggerLabel="Show independent destination choices" />
<div className="flex flex-wrap gap-3"><Button style={actionStyle} type="submit" disabled={disabled || pending}>Submit registered local choice</Button><Button style={actionStyle} type="button" variant="outline" disabled={disabled || readOnly || pending} onClick={()=>{setValue('');setQuery('');setError('');input.current?.focus();}}>Clear registered local choice</Button></div>
</form>
<p data-combobox-state>Committed ID: {value || 'none'}. Query: {query || '(empty)'}. <span data-combobox-count>{count}</span> actual suggestion activations.</p>
<h3 ref={heading} tabIndex={-1} data-combobox-heading className="font-semibold">Loaded registered destination preview</h3><p data-combobox-loaded>{options.find(option=>option.value===loaded)?.text}</p>
<p data-combobox-payload style={{overflowWrap:'anywhere'}}>Actual native form entries: {payload}</p>
{pending && <div className="flex flex-wrap gap-3"><Button style={actionStyle} type="button" onClick={()=>outcome('complete')}>Complete registered local choice</Button><Button style={actionStyle} type="button" variant="outline" onClick={()=>outcome('fail')}>Fail registered local choice</Button><Button style={actionStyle} type="button" variant="outline" onClick={()=>outcome('cancel')}>Cancel registered local choice</Button></div>}
<p role="status" data-combobox-status>{status}</p>
</div>;
}
Public value and query API
| Prop | Ownership |
|---|---|
| options | Unique stable string value, useful label and optional disabled flag. Caller owns current availability and provenance. |
| value / onChange | Controlled last committed ID and string callback. Empty string is the public empty sentinel, bridged to primitive null. Callback is not a DOM change event or query change. |
| defaultValue | Uncontrolled initial registered ID. Later changes are not an external reset; do not switch control modes. |
| inputValue / onInputValueChange | Independent controlled query/display text and string callback. Parent must reflect query proposals separately from committed ID proposals. |
| defaultInputValue | Uncontrolled initial query. Without query control, the primitive owns editing and selected-label synchronization. |
| onOptionSelect | Actual activated option object. Selection changes report through the primitive acceptance event, including drag release; a deduplicated click fallback covers unchanged reselection. This is an activation event, not stored-backend evidence. |
| name / required | Native hidden-ID form registration and required state. Visible search text does not replace the committed ID. Application validation must also handle uncommitted text and availability. |
| disabled / readOnly | Prevent editing/activation and close the popup. Disabled controls are omitted from native entries; read-only selection remains submitted. |
| emptyMessage / triggerLabel | Localizable no-match ReactNode and trigger name. Defaults describe registered choices; use field-specific names for several instances. |
| id / labels / descriptions / ref | Native input identity, accessible naming/error relationships and HTMLInputElement ref. Installed list/option identities are independent per root. |
| className / style / dir / native handlers | Native input styling/attributes and composed focus, key, blur and composition handlers. Explicit direction also reaches wrapper and portal. |
| Customization boundary | The high-level wrapper is single selection with built-in filtering/list composition. It does not expose all primitive modes, multiple chips, remote loading or virtualized-source policy. |
Committed value versus draft search
- Query editing filters available choices; it does not itself create a registered ID. The field retains the last committed selection until another choice or clear. Inspect actual native entries and implement the application’s submit policy.
- The lab rejects an unmatched query before using the hidden ID. Native required registration alone cannot prove a query matches an available selected record. A product can instead deliberately revert uncommitted search on dismissal; test that policy explicitly.
- Clearing must not restore the old label through a query || selectedLabel fallback. The primitive now owns separate input and selected values; empty text and empty selection are real states.
- If a selected ID is removed/restricted by new data, reconcile it against actual availability, retain useful task context and explain recovery. A stale external ID can display its raw string; caller validation must not silently promote it.
- Do not infer selection from label equality across ambiguous records. Labels should be useful and values unique; this component serializes actual registered identity.
Keyboard, pointer and focus
- The input remains a text editor with combobox semantics. Arrow navigation highlights actual registered options while input retains DOM focus; Enter accepts an available highlighted option. Preserve native caret/selection/composition behavior.
- Escape dismisses the popup. The installed primitive owns query/selected-label synchronization on dismissal. Enter on an unmatched draft submits to the form policy, which rejects it, then restores the committed label. The rejection and loaded result remain unchanged; this is not a newly accepted record. Verify any consuming policy that needs to retain unmatched text.
- The launcher provides explicit pointer/keyboard disclosure with a useful name and full target. It becomes unavailable with disabled/read-only state.
- Pointer click, keyboard acceptance and the primitive’s drag-release path report the actual option. Selecting a suggestion does not itself submit the application form.
- Avoid nested interactive commands inside an option. Keep cancellation, validation and independent restoration outside unavailable fields.
States and recovery invariants
| State | Required outcome |
|---|---|
| Empty / required | No registered value; persistent label and empty prompt remain. Invalid submit focuses the input and connects the visible error. |
| Query / no match | Preserve meaningful search/recovery. Arbitrary text is not a registered record or a success result. |
| Highlighted / selected | Active descendant refers to this instance’s actual option. Check/bold state identifies committed selection independently from highlight. |
| Disabled / read-only | No mutation or open popup. Native form entries distinguish omission from retained read-only selection. |
| External reset / clear | Actual input/query/selection reflect application ownership. Clear must not repopulate from the old ID. |
| Requested / pending | Capture actual native IDs, prevent duplicates and preserve the loaded preview with reachable outcomes. |
| Failed / cancelled | Keep draft choice/query and loaded content; retry requires current valid values. |
| Completed / stale options | Only completion changes real application content. Revalidate identity and availability against the actual data model. |
Migration and verification
- The legacy hand-written loop had global option IDs, query fallback that prevented clearing, disappearing disabled options and a delayed blur timer. The primitive now owns registration, virtual focus, composition and disclosure.
- Native name serialization is now registered identity rather than the visible input label. Review consumers expecting labels and keep backend authorization independent.
- Use defaultValue for uncontrolled initial IDs; use the separate query API for controlled search. Native event handlers compose rather than replace all internal interactions.
- Run test:combobox, checked-source/token freshness, lint, UI/styleguide types, production build and the complete route/fragment crawl. Verify actual IDs, displayed labels, query correction, callback counts, independent instances and real preview outcomes.
- Test both themes, narrow bounds/reflow, long-label scrolling, RTL, reduced motion, forced colors and a named assistive-technology/device pair. Browser DOM checks do not certify every IME, remote race or consumer integration.