Advanced field compositions
Native forms, shared input frames, grouped selections, editable tags and asynchronous recovery in a themed dialog.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useState } from 'react';
import { Eye, EyeOff } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
export function PasswordField() {
const [visible, setVisible] = useState(false);
return <TextField name="password" label="Password" autoComplete="new-password"
type={visible ? 'text' : 'password'} description="Use at least twelve characters."
endAdornment={<IconButton type="button" variant="ghost"
aria-label={visible ? 'Hide password' : 'Show password'} aria-pressed={visible}
onClick={() => setVisible(value => !value)}>
<Icon icon={visible ? EyeOff : Eye} />
</IconButton>} />;
}API reference: Icon, Button, Text Field.
Shared field anatomy
Use TextField for a single-line input and TextareaField for multiline content. Both apply the same field label, description and error profiles, preserve native refs and form associations, and accept zero-valued helper content. TextareaField can autosize between minRows and maxRows while retaining native reset behavior. FieldLayout passes stable accessible attributes to native selects, custom selection triggers and number inputs.
TextField accepts startAdornment and endAdornment. The containing InputGroup owns one border and focus ring, derives invalid/read-only/disabled appearance from the actual input, and keeps adornments as siblings. Interactive adornments need their own names and disabled state. Use frame={false} for an intentionally frameless sibling layout.
Selection and asynchronous ownership
Select.Root and Combobox.Root preserve their original generic value types. Grouped and multiple values, serialization, comparison and labels remain explicit. The example renders the completed Chip inside Combobox.Chip and uses Combobox.ChipRemove for the selection engine's keyboard/focus behavior. New tag values are owned by this example's application state.
The destination search uses AbortController, cancels superseded requests and refuses stale results. Query “old” takes longer than other queries; “fail” simulates a retryable error. The form's first save fails deliberately, then retry succeeds. Values remain editable and retained after error or cancellation. Reset restores the initial draft. These are local deterministic simulations, not a backend or a library-owned query cache.
Native behavior
Names, required fields, FormData, refs, autocomplete hints and external form association reach the actual control. Password visibility changes only the input type. Controlled clear/count/reset operations belong to the application. Do not intercept composition events to implement filtering or shortcuts; continue to use the native composition and change stream.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Compose a collection
This local form deliberately fails its first save so draft retention and retry can be tried. No server is contacted.
Read the complete example
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { Eye, EyeOff, X, Search, Plus, Minus } from '@lucide/icons';
import { Heading, Text } from '@fayegram-ai/faye-ui/core/text';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Stack, Inline, Grid } from '@fayegram-ai/faye-ui/layout/layout';
import { ThemeScope } from '@fayegram-ai/faye-ui/provider/provider';
import { Button, IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { TextareaField } from '@fayegram-ai/faye-ui/controls/textarea-field';
import { FieldLayout } from '@fayegram-ai/faye-ui/controls/field-layout';
import { NativeSelect } from '@fayegram-ai/faye-ui/controls/native-select';
import { Select } from '@fayegram-ai/faye-ui/controls/select';
import { Combobox } from '@fayegram-ai/faye-ui/controls/combobox';
import { NumberField } from '@fayegram-ai/faye-ui/controls/number-field';
import { Chip } from '@fayegram-ai/faye-ui/controls/chip';
import { Modal } from '@fayegram-ai/faye-ui/overlays/modal';
import { Spinner } from '@fayegram-ai/faye-ui/feedback/loading';
import { LinearProgress } from '@fayegram-ai/faye-ui/feedback/progress';
// A deterministic local service demonstrates ownership; no backend is contacted.
function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(new DOMException('Canceled', 'AbortError')); };
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, milliseconds);
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
});
}
const destinations = [{ id: 'lisbon', label: 'Lisbon' }, { id: 'oslo', label: 'Oslo' }, { id: 'tokyo', label: 'Tokyo' }, { id: 'old-town', label: 'Old town' }];
const topicGroups = [{ label: 'Outside', items: ['Nature', 'Walking'] }, { label: 'Culture', items: ['Design', 'Food'] }];
const knownTags = ['Weekend', 'Friends', 'Photography'];
export function AsyncDestination() {
const id = useId(), [query, setQuery] = useState(''), [retry, setRetry] = useState(0);
const [selected, setSelected] = useState<(typeof destinations)[number] | null>(null);
const [results, setResults] = useState<typeof destinations>([]), [status, setStatus] = useState('idle');
const request = useRef<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController(); request.current = controller;
if (!query.trim()) { setResults([]); setStatus('idle'); return () => controller.abort(); }
setStatus('loading'); setResults([]);
wait(query.toLowerCase() === 'old' ? 450 : 150, controller.signal).then(() => {
if (controller.signal.aborted) return;
if (query.toLowerCase() === 'fail' && retry === 0) throw Error('Search unavailable');
setResults(query.toLowerCase() === 'fail' ? destinations : destinations.filter(item => item.label.toLowerCase().includes(query.toLowerCase())));
setStatus('ready');
}).catch(() => { if (!controller.signal.aborted) setStatus('error'); });
return () => { controller.abort(); if (request.current === controller) request.current = null; };
}, [query, retry]);
return <Stack gap="2" data-async-selection>
<Combobox.Root items={results} filter={null} value={selected} onValueChange={value => { setSelected(value); if (value) setQuery(value.label); }} inputValue={query} onInputValueChange={(value, details) => { if (details.reason === 'input-change' || details.reason === 'clear-press') { setQuery(value); setSelected(current => current?.label === value ? current : null); } else details.cancel(); }} itemToStringLabel={item => item.label} itemToStringValue={item => item.id} isItemEqualToValue={(a, b) => a.id === b.id} name="destination">
<Combobox.Status className="docs-search-status" data-search-status>{status === 'loading' ? 'Searching destinations…' : status === 'error' ? 'Search unavailable. Your query is retained.' : status === 'canceled' ? 'Search canceled.' : status === 'ready' ? `${results.length} ${results.length === 1 ? 'destination' : 'destinations'} found.` : 'Enter a destination.'}</Combobox.Status>
<FieldLayout controlId={`${id}-destination`} label="Find a destination" description="Type a city. In this local example, “fail” simulates a retryable error.">
{attrs => <Combobox.InputGroup><Icon icon={Search} size="sm"/><Combobox.Input {...attrs} aria-busy={status === 'loading' || undefined}/><Combobox.Clear aria-label="Clear destination search"/></Combobox.InputGroup>}
</FieldLayout>
<Combobox.Portal><Combobox.Positioner><Combobox.Popup className="docs-async-popup">
<Inline wrap>{status === 'error' && <Button onClick={() => setRetry(value => value + 1)}>Retry destination search</Button>}
<Button disabled={status !== 'loading'} onClick={() => { request.current?.abort(); setStatus('canceled'); }}>Cancel destination search</Button></Inline>
<Combobox.List>{(item: (typeof destinations)[number]) => <Combobox.Item key={item.id} value={item}>{item.label}<Combobox.ItemIndicator/></Combobox.Item>}</Combobox.List>
<Combobox.Empty>{status === 'loading' ? <Inline gap="2"><Spinner size="sm" aria-hidden="true"/>Searching…</Inline> : status === 'error' ? 'Search unavailable. Use Retry destination search.' : 'No destinations found.'}</Combobox.Empty>
</Combobox.Popup></Combobox.Positioner></Combobox.Portal>
</Combobox.Root>
</Stack>;
}
export default function AdvancedFields() {
const id = useId(), [open, setOpen] = useState(false), [visible, setVisible] = useState(false);
const [title, setTitle] = useState('Weekend stories'), [description, setDescription] = useState('A few places worth remembering.'), [secret, setSecret] = useState('');
const [access, setAccess] = useState('private'), [topics, setTopics] = useState<string[]>(['Nature']), [tags, setTags] = useState<string[]>(['Weekend']), [tagQuery, setTagQuery] = useState('');
const [limit, setLimit] = useState<number | null>(3), [pending, setPending] = useState(false), [error, setError] = useState(''), [titleError, setTitleError] = useState(''), [result, setResult] = useState('');
const titleRef = useRef<HTMLInputElement>(null), tagRef = useRef<HTMLInputElement>(null), request = useRef<AbortController | null>(null), attempts = useRef(0);
useEffect(() => () => request.current?.abort(), []);
const cancel = () => { request.current?.abort(); request.current = null; setPending(false); };
const reset = () => { cancel(); setTitle('Weekend stories'); setDescription('A few places worth remembering.'); setSecret(''); setAccess('private'); setTopics(['Nature']); setTags(['Weekend']); setTagQuery(''); setLimit(3); setError(''); setTitleError(''); setResult(''); };
const tagItems = [...new Set([...knownTags, ...tags, ...(tagQuery.trim() ? [tagQuery.trim()] : [])])];
return <ThemeScope variables={{ '--faye-ui-radius-control': '10px' }}>
<Stack data-advanced-fields>
<Heading level={2} size="md">Compose a collection</Heading>
<Text as="p">This local form deliberately fails its first save so draft retention and retry can be tried. No server is contacted.</Text>
<Inline><Button onClick={() => setOpen(true)}>Edit collection form</Button></Inline>
<Text as="p" role="status" data-editor-result>{result}</Text>
<AsyncDestination/>
<Modal open={open} onOpenChange={value => { if (!value) cancel(); setOpen(value); }} title="Edit your collection" description="Keep the details that matter to you." size="lg">
<form noValidate data-advanced-form onReset={reset} onSubmit={async event => {
event.preventDefault();
if (request.current) return;
if (!title.trim()) { setTitleError('Give your collection a name.'); titleRef.current?.focus(); return; }
const controller = new AbortController(); request.current = controller;
const values = new FormData(event.currentTarget); setPending(true); setError(''); setTitleError('');
try {
await wait(280, controller.signal);
if (controller.signal.aborted) return;
if (++attempts.current === 1) throw Error('Save failed. Your changes are still here. Try again.');
setResult(`Saved ${values.get('title')}; topics: ${values.getAll('topics').join(', ')}; tags: ${values.getAll('tags').join(', ')}`);
setOpen(false);
} catch (caught) { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : 'Save failed.'); }
finally { if (request.current === controller) { request.current = null; setPending(false); } }
}}><Stack gap="4">
<TextField ref={titleRef} name="title" label="Collection name" required value={title} error={titleError || undefined} onValueChange={setTitle} endAdornment={<IconButton type="button" variant="ghost" aria-label="Clear collection name" disabled={!title} onClick={() => { setTitle(''); titleRef.current?.focus(); }}><Icon icon={X} size="sm"/></IconButton>}/>
<TextareaField name="description" label="Collection description" autosize minRows={2} maxRows={5} maxLength={240} value={description} onChange={event => setDescription(event.target.value)} description={`${description.length}/240 characters`}/>
<TextField name="secret" label="Access phrase" type={visible ? 'text' : 'password'} autoComplete="new-password" value={secret} onValueChange={setSecret} description="Optional; used only in this local example." endAdornment={<IconButton type="button" variant="ghost" aria-label={visible ? 'Hide access phrase' : 'Show access phrase'} aria-pressed={visible} onClick={() => setVisible(value => !value)}><Icon icon={visible ? EyeOff : Eye} size="sm"/></IconButton>}/>
<Grid columns={{ base: 1, md: 2 }}>
<FieldLayout label="Visibility">{attrs => <NativeSelect {...attrs} name="access" value={access} onChange={event => setAccess(event.target.value)}><optgroup label="Audience"><option value="private">Only me</option><option value="friends">Friends</option></optgroup><optgroup label="Open"><option value="public">Everyone</option></optgroup></NativeSelect>}</FieldLayout>
<Select.Root multiple name="topics" value={topics} onValueChange={setTopics} items={Object.fromEntries(topicGroups.flatMap(group => group.items).map(item => [item, item]))}>
<FieldLayout label="Topics">{attrs => <Select.Trigger {...attrs}><Select.Value placeholder="Choose topics"/><Select.Icon/></Select.Trigger>}</FieldLayout>
<Select.Portal><Select.Positioner><Select.Popup><Select.List>{topicGroups.map(group => <Select.Group key={group.label}><Select.GroupLabel>{group.label}</Select.GroupLabel>{group.items.map(item => <Select.Item key={item} value={item}><Select.ItemText>{item}</Select.ItemText><Select.ItemIndicator/></Select.Item>)}</Select.Group>)}</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</Grid>
<FieldLayout controlId={`${id}-tags`} label="Collection tags" description="Choose an existing tag or type a new one.">{attrs => <Combobox.Root multiple name="tags" items={tagItems} value={tags} onValueChange={setTags} inputValue={tagQuery} onInputValueChange={setTagQuery}>
<Combobox.Chips><Combobox.Value>{(values: string[]) => values.map(tag => <Combobox.Chip key={tag} render={<Chip size="sm" trailing={<Combobox.ChipRemove aria-label={`Remove ${tag} tag`}><Icon icon={X} size="sm"/></Combobox.ChipRemove>}>{tag}</Chip>}>{tag}</Combobox.Chip>)}</Combobox.Value><Combobox.Input {...attrs} ref={tagRef} placeholder="Add a tag"/></Combobox.Chips>
<Combobox.Portal><Combobox.Positioner><Combobox.Popup><Combobox.List>{(tag: string) => <Combobox.Item key={tag} value={tag}>{knownTags.includes(tag) || tags.includes(tag) ? tag : `Create ${tag}`}<Combobox.ItemIndicator/></Combobox.Item>}</Combobox.List><Combobox.Empty>No tags found.</Combobox.Empty></Combobox.Popup></Combobox.Positioner></Combobox.Portal>
</Combobox.Root>}</FieldLayout>
<NumberField.Root name="limit" value={limit} onValueChange={setLimit} min={1} max={12} step={1}>
<FieldLayout label="Places to visit">{attrs => <NumberField.Group><NumberField.Decrement aria-label="Fewer places"><Icon icon={Minus} size="sm"/></NumberField.Decrement><NumberField.Input {...attrs}/><NumberField.Increment aria-label="More places"><Icon icon={Plus} size="sm"/></NumberField.Increment></NumberField.Group>}</FieldLayout>
</NumberField.Root>
{error && <Text as="p" tone="danger" role="alert">{error}</Text>}
{pending && <LinearProgress label="Saving your collection"/>}
<Inline wrap><Button type="submit" variant="solid" tone="accent" pending={pending}>{error ? 'Retry collection save' : 'Save collection details'}</Button>{pending ? <Button type="button" onClick={cancel}>Cancel collection save</Button> : <Button type="reset">Reset collection form</Button>}</Inline>
</Stack></form>
</Modal>
</Stack>
</ThemeScope>;
}