MobTranslate styleguide

Autocomplete

Editable text with registered suggestions, independent instance identities, native form values and recoverable local outcomes.

Choose text or a registered value

Autocomplete assists a text field without restricting its text to a suggestion. A suggestion label becomes editable text; its stable value is separate metadata. Use Combobox when a registered option identity is the actual field value, or Select when editing/search is unnecessary.

This wrapper uses the pinned Base UI autocomplete in list mode. It filters the caller’s actual options and does not generate vocabulary, infer synonyms, contact a provider or establish source authority. Prefer useful distinct labels and stable unique option values.

  • Give each field a persistent label and purpose. Placeholder text is not an accessible name.
  • Define whether free text is valid at submission. This component allows it; the lab requires only nonempty text.
  • Do not treat a highlighted suggestion, an activated suggestion, submitted text and a persisted record as equivalent outcomes.

Anatomy and specifications

PartActual contract
InputNative input/ref with combobox semantics. Full width, 44px minimum height, 12px inline padding, 2px boundary and medium radius. Caller className targets this input.
Root / wrapperInstalled no-visible-node Root and ordinary containing div. Own text/open state is independent for each instance; public options/value/defaultValue compose this root.
Positioner / popupPortalled collision-aware positioning with 8px side offset/padding. Single bordered popover surface, anchor-width minimum and viewport-width maximum; at most 320px/available height.
List / optionActual filtered list and registered div options; minimum 44px option height and 8px × 12px padding. Long labels wrap and the bounded list scrolls.
Highlight / focusInput retains DOM focus while aria-activedescendant identifies an actual option. Independent 2px highlight/focus cues; highlight is not a committed record identity.
Empty / unavailableA visible no-match message preserves arbitrary text. Disabled suggestions remain meaningful unavailable options and reject activation.
Theme / directionText and boundaries use shared roles; placeholder uses readable muted foreground. Explicit dir reaches both the inline wrapper/input and portal. Forced colors retain focus/boundary cues.

Real text, identity and local recovery

Type your own note or activate an English-only suggestion. Both paths update controlled text and native FormData; accepted option metadata is shown separately and cleared by later editing/restoration. The second instance has independent text and list identities. Submit a nonempty note, then explicitly complete, fail or cancel; only completion changes the actual loaded preview.

Restore external local text proves that a controlled value update reaches the input. Clear can produce an actually empty field. Disabled/read-only modes reject edits, pending mode retains values and an unavailable suggestion remains unselectable. No remote save or durable storage is claimed.

This field submits text, including an accepted label. A suggestion’s stable value is separate metadata and is not submitted as a record identifier.

Two instances have separate text, list identities and active-descendant relationships.

Last accepted suggestion metadata: none. Editing or restoring text clears that metadata. 0 activation callbacks in this local page.

Loaded local note preview

English-only initial local note

Actual native form entries: No native form submission.

Free text is allowed. Suggestions do not establish source authority.

tsx
'use client';
import {useRef,useState,type CSSProperties} from 'react';
import {Autocomplete,Button} from '@mobtranslate/ui';

const actionStyle:CSSProperties={maxWidth:'100%',minWidth:0,minHeight:44,height:'auto',whiteSpace:'normal',overflowWrap:'anywhere'};
const options=[{value:'overview',label:'Local workspace overview'},{value:'reading',label:'Local reading notes'},{value:'saved',label:'Local saved examples'},{value:'restricted',label:'Reviewed source unavailable',disabled:true}];
export function AutocompleteTextExample(){
  const [text,setText]=useState('');
  const [other,setOther]=useState('');
  const [accepted,setAccepted]=useState('none');
  const [acceptCount,setAcceptCount]=useState(0);
  const [disabled,setDisabled]=useState(false);
  const [readOnly,setReadOnly]=useState(false);
  const [rtl,setRtl]=useState(false);
  const [pending,setPending]=useState(false);
  const [requested,setRequested]=useState<string | null>(null);
  const [loaded,setLoaded]=useState('English-only initial local note');
  const [payload,setPayload]=useState('No native form submission.');
  const [error,setError]=useState('');
  const [status,setStatus]=useState('Free text is allowed. Suggestions do not establish source authority.');
  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('Local text preview updated. No remote save occurred.');}
    if(result==='fail')setStatus('Local text request failed. Loaded preview and draft remain; submit again to retry.');
    if(result==='cancel')setStatus('Local text request cancelled. Loaded preview and draft remain.');
    setPending(false);setRequested(null);heading.current?.focus();
  }
  return <div data-autocomplete-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(value=>!value)}>Disable local suggestion inputs</Button><Button style={actionStyle} type="button" variant="outline" disabled={pending} aria-pressed={readOnly} onClick={()=>setReadOnly(value=>!value)}>Make local suggestion inputs read only</Button><Button style={actionStyle} type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL local suggestions</Button><Button style={actionStyle} type="button" variant="outline" disabled={pending} onClick={()=>{setText('Restored external local text');setAccepted('none');setError('');setDisabled(false);setReadOnly(false);}}>Restore external local text</Button></div>
    <form noValidate className="space-y-4" onSubmit={event=>{event.preventDefault();if(pending || disabled)return;if(!text.trim()){setError('Enter a nonempty local note; choosing a suggestion is optional.');input.current?.focus();return;}const data=new FormData(event.currentTarget);setPayload(JSON.stringify([...data.entries()]));setRequested(String(data.get('note')));setPending(true);setError('');setStatus('Local text requested; the loaded preview remains until an explicit outcome.');heading.current?.focus();}}>
      <label htmlFor="autocomplete-local-note" className="block font-semibold">Local note text</label>
      <Autocomplete ref={input} id="autocomplete-local-note" name="note" required options={options} value={text} onChange={value=>{setText(value);setAccepted('none');setError('');}} onOptionSelect={option=>{setAccepted(option.value);setAcceptCount(count=>count+1);}} disabled={disabled || pending} readOnly={readOnly} dir={rtl ? 'rtl' : 'ltr'} aria-describedby={error ? 'autocomplete-note-help autocomplete-note-error' : 'autocomplete-note-help'} aria-invalid={!!error} placeholder="Type your own local note or use a suggestion" />
      <p id="autocomplete-note-help">This field submits text, including an accepted label. A suggestion’s stable value is separate metadata and is not submitted as a record identifier.</p>
      {error && <p id="autocomplete-note-error" role="alert">{error}</p>}
      <label htmlFor="autocomplete-independent" className="block font-semibold">Independent optional local note</label>
      <Autocomplete id="autocomplete-independent" name="otherNote" options={options} value={other} onChange={setOther} disabled={disabled || pending} readOnly={readOnly} dir={rtl ? 'rtl' : 'ltr'} />
      <p>Two instances have separate text, list identities and active-descendant relationships.</p>
      <div className="flex flex-wrap gap-3"><Button style={actionStyle} type="submit" disabled={disabled || pending}>Submit local suggestion text</Button><Button style={actionStyle} type="button" variant="outline" disabled={disabled || readOnly || pending} onClick={()=>{setText('');setAccepted('none');setError('');input.current?.focus();}}>Clear local suggestion text</Button></div>
    </form>
    <p data-autocomplete-metadata>Last accepted suggestion metadata: {accepted}. Editing or restoring text clears that metadata. <span data-autocomplete-accept-count>{acceptCount}</span> activation callbacks in this local page.</p>
    <h3 ref={heading} tabIndex={-1} data-autocomplete-heading className="font-semibold">Loaded local note preview</h3><p data-autocomplete-loaded>{loaded}</p>
    <p data-autocomplete-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 local suggestion text</Button><Button style={actionStyle} type="button" variant="outline" onClick={()=>outcome('fail')}>Fail local suggestion text</Button><Button style={actionStyle} type="button" variant="outline" onClick={()=>outcome('cancel')}>Cancel local suggestion text</Button></div>}
    <p role="status" data-autocomplete-status>{status}</p>
  </div>;
}

Public API and native contracts

PropOwnership
optionsArray of value/label records with optional disabled. Values must be unique; labels must predict the inserted text. Options are caller-owned data.
value / onChangeControlled text string and text-change callback. Parent must reflect proposed changes. Native editing and suggestion acceptance both update text; onChange is not a DOM event callback.
defaultValueUncontrolled initial text string. Later defaultValue changes do not reset the field; do not switch controlled/uncontrolled modes during its lifecycle.
onOptionSelectReceives the actual activated option object for pointer or keyboard choice. The wrapper reports metadata after the primitive text event, covers drag-release acceptance, and deduplicates a queued click fallback when the primitive has already reported that activation. Delivery is suppressed after unmount. It does not submit the owning form or certify a record.
name / requiredNative text submission and required registration. The lab uses noValidate with explicit visible correction; suggestion choice itself is not required.
disabled / readOnlyDisable editing/activation and close the popup. Disabled text is omitted from FormData; read-only text remains submitted. Restoration retains the controlled text.
id / labels / ARIA / refNative input identity, accessible naming/descriptions/errors and actual HTMLInputElement ref. Installed instance-generated list/option identities replace the old global ids.
onFocus / onBlur / onKeyDown / composition propsForward native handlers through the primitive’s composition, preserving installed interactions. Handle actual native events; avoid replacing the combobox’s internal roles or relationships.
className / style / dir / other input propsInput styling and native attributes. Explicit dir is propagated into the portal; label language and direction remain separate concerns.
emptyMessageOptional ReactNode for meaningful localized no-match/recovery copy. Default text keeps free text explicitly valid; do not announce it as source verification.
Filtering / popup customizationThis high-level wrapper fixes list mode and built-in filtered options and positioning. It does not expose a remote loader or every primitive mode/part. Applications requiring other modes need a deliberate lower-level composition and acceptance checks.

Keyboard, pointer and text editing

  • Click the input or type to expose matching suggestions. Arrow navigation highlights registered options while DOM focus remains in the input; Enter accepts an available highlighted suggestion.
  • Escape dismisses suggestions while keeping editable text. With no matching/highlighted suggestion, ordinary form/text behavior remains the caller’s responsibility. Do not convert every Enter into an unrelated selection.
  • Native caret movement, deletion and selection must remain text editing. The pinned primitive handles composition rather than running a hand-written option loop on every key; real IME/device evidence is still required before claiming platform coverage.
  • Option activation inserts its label and reports the actual option object. Choosing a suggestion does not automatically submit the form in this wrapper.
  • Moving focus outside dismisses the popup. Pointer handling preserves input focus without the old delayed blur timer; custom option actions must not introduce nested focusable commands.

States and task invariants

StateRequired behavior
Empty / editingAn empty controlled value remains empty; arbitrary text is preserved rather than replaced by a prior label.
External updateThe actual input reflects a new controlled string. Previously the component copied its initial value only once.
Open / highlightedRelationships point to an actual current instance option. Highlight does not mutate a stored record identifier.
Accepted suggestionText callback receives the label and option callback receives metadata. Editing later invalidates any caller-held accepted identity.
No match / removed dataKeep text and explain that no registered suggestions match. Changing options must not leave a stale active descendant.
Disabled / read-onlyNo popup or option mutation; native successful-control rules determine omission versus retention.
Requested / pendingCapture actual form entries, reject duplicates, retain loaded text and offer reachable explicit outcomes.
Failure / cancel / successFailure/cancel retain draft and loaded preview; completion updates real visible text. A local outcome is not remote persistence.

Migration and common failures

  • The public controlled value now stays controlled instead of being copied into isolated initial state. Use defaultValue for an uncontrolled initial value and onChange for updates.
  • Suggestion acceptance now reports the new text through onChange as well as metadata through onOptionSelect. Account for this when triggering application validation or searches; do not depend on the former missing callback.
  • Do not serialize option.value from an Autocomplete text field and silently treat it as the entered text. Conversely, a label alone is not a stable database identity.
  • Do not assign global index-based option ids to multiple instances or let custom native handlers replace all internal keyboard/focus behavior.
  • Do not use a 150ms blur timer as the foundation of pointer selection. The mature primitive owns focus and interaction; source-specific authority checks remain outside it.

Verification and release scope

  • Run test:autocomplete, checked-source/token freshness, shared UI/styleguide typechecks, lint, production build and the complete route/fragment crawl.
  • Prove actual controlled reset/clear, both text and metadata callbacks, distinct instance relationships, native form payloads, arbitrary/no-match text, unavailable/read-only/disabled enforcement, and retained versus completed preview.
  • Check input focus, keyboard acceptance/dismissal, narrow popup bounds, long-label scrolling, both-theme text/focus contrast, RTL, reduced motion and forced colors.
  • Browser DOM evidence does not prove every IME/device, spoken assistive-technology result, remote suggestion race or consumer integration. Qualify those acceptance boundaries explicitly.