Selection and Filters
Choose a control by the task’s value model and distinguish intended, applied and returned results.
Choose by the value model
| Task | Control | Value |
|---|---|---|
| Include independent options | Checkbox/CheckboxGroup | Boolean or selected set; aggregate may be mixed. |
| Choose one alternative | RadioGroup | One member value. |
| Turn a feature on/off | Switch | Retained boolean setting. |
| Press an optional action/view | Toggle/ToggleGroup | Boolean or array; none may be allowed. |
| Compact filter/action/input | ChipToggle / ChipAction / removable Chip | Pressed filter, explicit action or input token. |
| Adjust a quantity/range | Slider plus exact alternative | Finite numeric value(s), named units and bounds. |
Indicators, parts and current destinations
Selection can cover an item or a named part. Use a check/checkbox and an associated state attribute alongside any surface fill. Navigation and tabs indicate the current destination/panel; they do not represent an independent batch-selected set. Use aria-current or the installed tab semantics for those tasks.
Focus identifies the next keyboard destination independently of selected/checked/pressed state. A surface color alone neither exposes the value to assistive technology nor establishes consent or review. Keep member names stable and inspect the actual state appropriate to each family.
Enter, extend and leave a selection task
When selection is the primary task, keep its controls visible. When it is secondary, an optional hover shortcut may add convenience, but touch and keyboard users still need a discoverable select action. The reference touch model enters selection with long press/shortcut, extends by tapping additional items and leaves by clearing members or an explicit action.
For web batch work, state the selected count and scope, keep an explicit clear action, and disclose selected records hidden by filtering or paging. A batch action should consume the selected IDs deliberately; its actual completed result is separate from the live selected set. Provide meaningful focus when controls disappear.
- Do not overload long-press-and-drag for both batch selection and item pickup in one list.
- Range or select-all must specify visible page, filtered results, loaded items or a remote scope; those are different sets.
- Choose how removed/unavailable IDs are reconciled before submitting; a stale visual count is not an authoritative request.
- Do not infer a voice control or touch selection implementation from a named DOM control alone; verify the actual input and assistive-technology pair.
Working page-scoped batch selection
This existing table specimen keeps primary selection controls visible. Its header selects only the visible page and becomes mixed for a partial page. The selected set survives sorting/filtering/paging by stable ID; status discloses off-page selections. Create local selection summary snapshots all selected loaded records, and clearing the live set retains that prior result. No remote batch action is performed.
Six synthetic English-only local collections. Filtering searches their titles; this is not a language translation, source audit or remote corpus search.
Scroll this named region horizontally to compare all columns. Use Tab to reach ordinary sort, selection and View controls; the table has no spreadsheet arrow-key model.
6 of 6 loaded collections match. Page 1 of 2. 0 selected; 0 outside this page.
| Loaded entries | Action | ||
|---|---|---|---|
| Local audio preparation | 3 | ||
| Local bookmarked pages | 4 | ||
| Local draft entries | 12 | ||
| Matching loaded entries, all filtered pages | 53 | 6 collections | |
No collection opened yet.
No selection summary created yet.
Filtering, paging and sorting retain selection and opened content by stable ID. The summary button snapshots all selected loaded records, including selections outside this page. Clear selection does not erase that prior outcome. No remote save, qualified review, playback or authorization is inferred.
'use client';
import {useRef, useState, type CSSProperties} from 'react';
import {Button, Checkbox, Input, Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption, Pagination, PaginationContent, PaginationItem, PaginationButton, PaginationPrevious, PaginationNext} from '@mobtranslate/ui';
const records = [
{id:'reading', title:'Local reading notes', count:18, detail:'English-only notes for a local reading task.'},
{id:'examples', title:'Local example collection', count:7, detail:'English-only examples for comparison; no source authority is implied.'},
{id:'drafts', title:'Local draft entries', count:12, detail:'English-only drafts awaiting a separate review workflow.'},
{id:'bookmarks', title:'Local bookmarked pages', count:4, detail:'English-only bookmarks retained in this fixture.'},
{id:'audio', title:'Local audio preparation', count:3, detail:'English-only preparation notes; these are not playable audio assets.'},
{id:'checklist', title:'Local verification checklist', count:9, detail:'English-only task checks; a listed check is not proof that it passed.'},
];
const action:CSSProperties = {minHeight:44, height:'auto', minWidth:0, maxWidth:'100%', whiteSpace:'normal', overflowWrap:'anywhere'};
const collator = new Intl.Collator('en', {numeric:true});
export function TableRecordsExample(){
const [query, setQuery] = useState('');
const [descending, setDescending] = useState(false);
const [page, setPage] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loaded, setLoaded] = useState<string|null>(null);
const [bundle, setBundle] = useState<string[]>([]);
const [rtl, setRtl] = useState(false);
const [compact, setCompact] = useState(false);
const table = useRef<HTMLTableElement>(null);
const container = useRef<HTMLDivElement>(null);
const filtered = records.filter(record=>record.title.toLocaleLowerCase('en').includes(query.trim().toLocaleLowerCase('en')))
.sort((a,b)=>(descending?-1:1)*collator.compare(a.title,b.title));
const pages = Math.max(1, Math.ceil(filtered.length/3));
const current = Math.min(page,pages-1);
const visible = filtered.slice(current*3,current*3+3);
const visibleSelected = visible.filter(record=>selected.has(record.id)).length;
const hiddenSelected = records.filter(record=>selected.has(record.id)&&!visible.some(row=>row.id===record.id)).length;
const loadedRecord = records.find(record=>record.id===loaded);
function toggleVisible(checked:boolean){setSelected(previous=>{
const next = new Set(previous);
visible.forEach(record=>checked?next.add(record.id):next.delete(record.id));
return next;
});}
return <div data-table-lab lang="en" dir={rtl?'rtl':'ltr'} className="space-y-4">
<p dir="auto">Six synthetic English-only local collections. Filtering searches their titles; this is not a language translation, source audit or remote corpus search.</p>
<label htmlFor="table-filter" className="block">Filter loaded collection titles</label>
<Input id="table-filter" value={query} onChange={event=>{setQuery(event.target.value);setPage(0);}} />
<div className="flex flex-wrap gap-3">
<Button style={action} variant="outline" type="button" onClick={()=>{setQuery('');setPage(0);}}>Clear title filter</Button>
<Button style={action} variant="outline" type="button" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL table direction</Button>
<Button style={action} variant="outline" type="button" aria-pressed={compact} onClick={()=>setCompact(value=>!value)}>Use compact cell padding</Button>
<Button style={action} variant="outline" type="button" onClick={()=>{container.current?.focus();table.current?.scrollIntoView({block:'center'});}}>Focus collection table</Button>
</div>
<p dir="auto" id="table-scroll-help">Scroll this named region horizontally to compare all columns. Use Tab to reach ordinary sort, selection and View controls; the table has no spreadsheet arrow-key model.</p>
<p dir="auto" role="status" data-table-status>{filtered.length} of {records.length} loaded collections match. Page {current+1} of {pages}. {selected.size} selected; {hiddenSelected} outside this page.</p>
<Table ref={table} containerRef={container} containerProps={{role:'region',tabIndex:0,'aria-label':'Loaded local collection comparison','aria-describedby':'table-scroll-help','data-table-container':true}} className={compact?'mt-table-compact':undefined} style={{minWidth:'42rem'}}>
<TableCaption>Local collections — loaded fixture only, three rows per page</TableCaption>
<TableHeader><TableRow>
<TableHead scope="col"><Checkbox label="Select visible page" checked={visible.length>0&&visibleSelected===visible.length} indeterminate={visibleSelected>0&&visibleSelected<visible.length} disabled={visible.length===0} onCheckedChange={toggleVisible} /></TableHead>
<TableHead scope="col" aria-sort={descending?'descending':'ascending'}><Button id="table-sort" style={action} type="button" variant="ghost" onClick={()=>{setDescending(value=>!value);setPage(0);}}>Collection title {descending?'↓':'↑'}</Button></TableHead>
<TableHead scope="col" style={{textAlign:'end'}}>Loaded entries</TableHead>
<TableHead scope="col">Action</TableHead>
</TableRow></TableHeader>
<TableBody>{visible.length?visible.map(record=><TableRow key={record.id} data-record-id={record.id} data-selected={selected.has(record.id)?'':undefined}>
<TableCell><Checkbox label={`Select ${record.title}`} checked={selected.has(record.id)} onCheckedChange={checked=>setSelected(previous=>{const next=new Set(previous);if(checked)next.add(record.id);else next.delete(record.id);return next;})} /></TableCell>
<TableHead scope="row" style={{whiteSpace:'normal'}}>{record.title}</TableHead>
<TableCell style={{textAlign:'end',fontVariantNumeric:'tabular-nums'}}>{record.count}</TableCell>
<TableCell><Button style={action} type="button" variant="outline" aria-label={`View ${record.title}`} onClick={()=>setLoaded(record.id)}>View</Button></TableCell>
</TableRow>):<TableRow><TableCell colSpan={4}>No loaded titles match. Clear the title filter to restore all six local records.</TableCell></TableRow>}</TableBody>
<TableFooter><TableRow><TableHead scope="row" colSpan={2}>Matching loaded entries, all filtered pages</TableHead><TableCell data-table-total style={{textAlign:'end',fontVariantNumeric:'tabular-nums'}}>{filtered.reduce((sum,record)=>sum+record.count,0)}</TableCell><TableCell>{filtered.length} collections</TableCell></TableRow></TableFooter>
</Table>
<Pagination aria-label="Loaded collection pages"><PaginationContent style={{flexWrap:'wrap'}}>
<PaginationItem><PaginationPrevious disabled={current===0} onClick={()=>setPage(current-1)} /></PaginationItem>
{Array.from({length:pages},(_,index)=><PaginationItem key={index}><PaginationButton aria-label={`Collection page ${index+1}`} isActive={current===index} onClick={()=>setPage(index)}>{index+1}</PaginationButton></PaginationItem>)}
<PaginationItem><PaginationNext disabled={current===pages-1} onClick={()=>setPage(current+1)} /></PaginationItem>
</PaginationContent></Pagination>
<div className="flex flex-wrap gap-3"><Button style={action} type="button" disabled={!selected.size} onClick={()=>setBundle(records.filter(record=>selected.has(record.id)).map(record=>record.title))}>Create local selection summary</Button><Button style={action} variant="outline" type="button" disabled={!selected.size} onClick={()=>setSelected(new Set())}>Clear all collection selections</Button></div>
<p dir="auto" role="status" data-table-result>{loadedRecord?`${loadedRecord.title}: ${loadedRecord.detail}`:'No collection opened yet.'}</p>
<p dir="auto" data-table-bundle>{bundle.length?`Actual local summary snapshot: ${bundle.join('; ')}.`:'No selection summary created yet.'}</p>
<p dir="auto">Filtering, paging and sorting retain selection and opened content by stable ID. The summary button snapshots all selected loaded records, including selections outside this page. Clear selection does not erase that prior outcome. No remote save, qualified review, playback or authorization is inferred.</p>
</div>;
}
Keep state stages separate
- Draft/intended: what the user selected. Applied: what the task accepted. Returned: what the data source actually delivered.
- A local filter can update a local list immediately. A remote query needs loading, empty, error and retry states and must retain the active query.
- Read-only/pending retains the prior value; disabled indicates an unavailable action. Neither state grants or denies cultural authority.
- Do not treat selected, checked or pressed as consent, qualified review or a completed server operation.
- Clear filters and independent recovery remain reachable when compact tokens are removed or notifications expire.
Working local filter, suggestion and recovery
Select local filters. Both filters apply together.
- Local reading sample
- Local note sample
No local chip action yet. Local filter: all bookmarks; all kinds. 2 matching local examples.
'use client';
import { useRef, useState } from 'react';
import { Button, Chip, ChipAction, ChipToggle, Input, type ChipProps } from '@mobtranslate/ui';
export function ChipSelectionExample() {
const [variant, setVariant] = useState<NonNullable<ChipProps['variant']>>('filled');
const [size, setSize] = useState<NonNullable<ChipProps['size']>>('md');
const [filtered, setFiltered] = useState(false);
const [notesOnly,setNotesOnly]=useState(false);
const [help,setHelp]=useState(false);
const [draft,setDraft]=useState('English-only local suggestion');
const [tokens,setTokens]=useState([{id:0,label:'English-only local suggestion'}]);
const [editing,setEditing]=useState<number|null>(0);
const [removed,setRemoved]=useState<{id:number;label:string}|null>(null);
const nextToken=useRef(0);
const [error,setError]=useState('');
const entry=useRef<HTMLInputElement>(null);
const visible=[{id:'reading',title:'Local reading sample',bookmarked:true,note:false},{id:'note',title:'Local note sample',bookmarked:false,note:true}].filter(item=>(!filtered||item.bookmarked)&&(!notesOnly||item.note));
const [result, setResult] = useState('No local chip action yet.');
const restore = useRef<HTMLElement>(null);
return <div data-chip-lab className="space-y-4">
<label>Chip variant <select data-chip-variant className="mt-input" value={variant} onChange={event => setVariant(event.target.value as typeof variant)}>{['filled','outlined','soft'].map(item => <option key={item}>{item}</option>)}</select></label>
<label>Chip size <select data-chip-size className="mt-input" value={size} onChange={event => setSize(event.target.value as typeof size)}>{['sm','md','lg'].map(item => <option key={item}>{item}</option>)}</select></label>
<div className="flex flex-wrap gap-3">{(['default','primary','success','warning','error'] as const).map(color => <Chip key={color} color={color} variant={variant} size={size} data-tone={color}>{color} local label</Chip>)}</div>
<div role="group" aria-label="Contextual local actions" className="flex flex-wrap gap-3"><ChipAction onClick={() => {setHelp(true);setResult('Local filter explanation is visible below.');}} variant={variant} size={size}>Explain local filter</ChipAction><ChipAction onClick={() => { if(!tokens.some(item=>item.label==='English-only local suggestion')){const id=++nextToken.current;setTokens(items=>[...items,{id,label:'English-only local suggestion'}]);}setError('');setResult('English-only local suggestion is present. Existing input chips are retained.'); }} variant={variant} size={size}>Add local suggestion</ChipAction><ChipAction disabled>Unavailable local action</ChipAction></div>
{help&&<p data-chip-help>Bookmarked examples includes retained bookmarks. Notes examples includes notes. Selecting both requires both conditions; clear filters to recover the complete local list.</p>}
<p>Select local filters. Both filters apply together.</p>
<div role="group" aria-label="Select local filters" className="flex flex-wrap gap-3"><ChipToggle pressed={filtered} onPressedChange={setFiltered} variant={variant} size={size}>Bookmarked examples</ChipToggle><ChipToggle pressed={notesOnly} onPressedChange={setNotesOnly} variant={variant} size={size}>Notes examples</ChipToggle></div>
<Button type="button" variant="outline" aria-disabled={!filtered&&!notesOnly||undefined} onClick={()=>{setFiltered(false);setNotesOnly(false);}}>Clear local chip filters</Button>
<form className="space-y-3" noValidate onSubmit={event=>{event.preventDefault();if(!draft.trim()){setError('Enter text for this local chip.');entry.current?.focus();return;}const label=draft.trim();if(tokens.some(item=>item.id!==editing&&item.label===label)){setError('A local chip already uses that exact text.');entry.current?.focus();return;}const existing=tokens.find(item=>item.id===editing);const id=existing?.id??++nextToken.current;setTokens(items=>existing?items.map(item=>item.id===id?{id,label}:item):[...items,{id,label}]);setEditing(id);setError('');setResult('Local input chip updated from the entered text. Other chips are retained.');}}>
<label htmlFor="local-chip-text">Local chip text</label><Input ref={entry} id="local-chip-text" value={draft} onChange={event=>{setDraft(event.target.value);setError('');}} aria-invalid={!!error||undefined} aria-describedby={error?'local-chip-error':undefined}/>
{error&&<p id="local-chip-error" role="alert">{error}</p>}
<div className="flex flex-wrap gap-3"><Button type="submit">Use local chip text</Button><Button type="button" variant="outline" onClick={()=>{setEditing(null);setDraft('');setError('');entry.current?.focus();}}>New local chip</Button></div>
<p>{editing===null?'Entering a new local chip.':'Editing an existing local chip.'} Exact duplicate text is not added twice.</p>
</form>
<div role="group" aria-label="Local input chips" className="flex flex-wrap gap-3">{tokens.map(token=><Chip key={token.id} onDelete={()=>{setRemoved(token);setTokens(items=>items.filter(item=>item.id!==token.id));if(editing===token.id)setEditing(null);setResult('Local input chip removed. Restore remains available.');restore.current?.focus();}} deleteLabel={`Remove ${token.label}`} variant={variant} size={size}><button type="button" className="mt-chip-action" aria-label={`Edit ${token.label}`} style={{minHeight:44,minWidth:44,maxWidth:'100%',padding:0,border:0,background:'transparent',color:'inherit',font:'inherit',textAlign:'start',whiteSpace:'normal',overflowWrap:'anywhere',cursor:'pointer'}} onClick={()=>{setEditing(token.id);setDraft(token.label);setError('');entry.current?.focus();}}>{token.label}</button></Chip>)}</div>
<Button ref={restore} style={{minHeight:44,height:"auto",minWidth:0,maxWidth:"100%",whiteSpace:"normal",overflowWrap:"anywhere"}} type="button" variant="outline" aria-disabled={!removed||undefined} onClick={()=>{if(removed){const exists=tokens.some(item=>item.id===removed.id||item.label===removed.label);if(!exists)setTokens(items=>[...items,removed]);setRemoved(null);setResult(exists?'A chip with the removed text is already present.':'Local input chip restored.');}}}>Restore local chip</Button>
<ul aria-label="Local filtered examples">{visible.map(item => <li key={item.id}>{item.title}</li>)}</ul>
{visible.length===0&&<p data-chip-empty>No local examples match both filters. Clear filters to show the retained examples.</p>}
<p role="status">{result} Local filter: {filtered ? 'bookmarked examples' : 'all bookmarks'}; {notesOnly?'notes examples':'all kinds'}. {visible.length} matching local examples.</p>
</div>;
}
Form and keyboard composition
Use a named fieldset/group for related choices. Inspect actual FormData, including omitted unchecked/disabled fields and repeated names. Toggle/chip pressed state needs explicit serialization when it is part of a request.
Preserve expected keyboard behavior of each widget rather than treating every control as a generic button. Provide meaningful focus after removal and usable numeric/single-pointer alternatives to dragging.
Dynamic data and source boundaries
Derive available filter options and aggregate members from the actual data. Reconcile removed/unavailable values deliberately and distinguish an empty result from an unavailable source. The sample list is finite English-only local data, not a language/resource inventory or rights/permission record.
Failure cases
- A filter that changes color but leaves the result list unchanged.
- A stale select-all set containing removed items.
- Exactly-one requirements implemented with a nullable toggle group without a consumer rule.
- Disabled controls changed by an unguarded outer click handler.
- Unknown numeric data converted into zero or a proficiency claim.
Verification and release scope
- Check actual names, roles, state and focus with pointer and keyboard. Inspect the retained value and submitted FormData when relevant.
- Exercise empty, selected, mixed, disabled, read-only, invalid and pending/recovery states that apply to the control. Styling alone does not prove enforcement.
- Check both themes, long labels, 320px reflow, meaningful graphic/text contrast, reduced motion, forced colors and the actual target device.
- Use a named screen-reader/browser pair to verify spoken state. DOM assertions and local examples do not prove announcements or backend persistence.
- Selection never establishes language quality, source authority, consent, qualified review or permission. Keep that evidence explicit in the product task.