MobTranslate styleguide

Table

Compare related records through native table structure, with explicit sorting, selection, totals and overflow ownership.

Choose comparison over decoration

Use a table when people compare the same attributes across records. Keep an individual record’s narrative, editing and source evidence in a separate detail view. A list suits one-dimensional scanning; a chart suits a visual trend when the values, units and underlying accessible data are also available.

These exports are semantic HTML parts. They do not implement a spreadsheet grid, server-side sorting, virtualization, editing, pagination or permission policy. Compose those tasks explicitly and preserve their state by stable record ID.

Anatomy and visual specifications

PartActual composition and dimensions
ContainerAutomatic div.mt-table-wrapper, full bounded width, min-width:0, overflow-x:auto, 2px readable border and radius-lg. Optional native props/ref name and focus the actual scroll surface.
Table / captionNative table, width:100%, collapsed borders and text-sm. Caption remains inside the table, 12px padding, logical start alignment and muted foreground.
Header / row headerNative thead/tr/th. Header cells use 12px block / 16px inline padding, foreground and a 2px lower boundary. Use scope=col or scope=row to express relationships.
Body / cellsNative tbody/tr/td; same regular padding and a 1px lower boundary. Values retain real units; numeric comparison can use logical end alignment and tabular numerals.
FooterNative tfoot with a precisely named aggregate. The example sums all matching loaded pages, not just the current page or a remote corpus.
Compact / selectionmt-table-compact changes cell padding to 8px / 12px. data-selected gives a muted row surface; actual checked state and selection count remain the semantic cues. Control targets remain at least 44px in this example.
Direction / overflowHeader/caption alignment follows logical start. Parent/container direction should match content direction; cells may isolate mixed-direction values. The checked example has a 42rem comparison width inside a named scroll region. English-only prose uses lang=en and dir=auto, keeping numeric status sentences readable while the table geometry is RTL.

Working loaded collection comparison

The checked source compares six synthetic English-only local records. Filter titles, sort ascending/descending, select individual or current-page records, page through results, open real record detail and create an actual selection summary. None of these synthetic values establish cultural review, a language corpus inventory or remote persistence.

Selections and opened content survive filtering, sorting and paging. Select visible page acts only on the current page; summary creation includes all selected loaded IDs. The previous summary remains an explicit snapshot when selection changes.

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.

Local collections — loaded fixture only, three rows per page
Loaded entriesAction
Local audio preparation3
Local bookmarked pages4
Local draft entries12
Matching loaded entries, all filtered pages536 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.

tsx
'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>;
}

Complete exported API

Export / propNative ownership
Table / TablePropsTableHTMLAttributes<HTMLTableElement>, native table ref, children/className/style. Ordinary table attributes, IDs and events reach the table, not its container. No data engine or default interactive region role.
containerProps / containerRefNative div attributes, data-* attributes and HTMLDivElement ref for the automatic overflow container. Its className composes with mt-table-wrapper. Name/focus/onScroll/dir belong here when they describe the scrolling surface.
TableHeader / TableBody / TableFooterNative thead/tbody/tfoot, section attributes and HTMLTableSectionElement refs. Footer is an exported part and must be included in API coverage.
TableRowNative tr, row attributes and HTMLTableRowElement ref. A background hover is not an activation contract; put actions in real controls.
TableHeadNative th, ThHTMLAttributes and HTMLTableCellElement ref. scope, colSpan, rowSpan and aria-sort remain caller-owned.
TableCellNative td, TdHTMLAttributes and HTMLTableCellElement ref. Keep header relationships intact when spanning cells.
TableCaptionNative caption, caption attributes and HTMLTableCaptionElement ref. Name the actual set/scope; do not substitute a generic “Data” caption.
Styling / stateRoot className can choose mt-table-compact or mt-table-striped. These are authored CSS classes, not nonexistent density/striped props. All parts compose caller native attributes/styles/classes.

Sorting and numeric comparison

  • Put a native button in a sortable column header. Apply aria-sort to the corresponding th and only the active sort column. A decorative arrow alone is insufficient.
  • Sort the actual record model, preserve stable IDs and make direction explicit. Use a locale-aware collator or appropriate numeric/date comparator; do not sort formatted quantities as opaque strings.
  • The fixture sorts English titles using Intl.Collator and resets to page one. A production remote sort must distinguish requested order from the order actually returned, handle stale responses and retain the prior result until its replacement is available.
  • Specify aggregate scope and units. The example’s footer totals loaded entries over all matching pages; it does not imply completeness beyond six loaded records. Missing values are not zero.

Selection, row actions and outcomes

  • Keep a real checkbox per selectable row and label it with the record. Current-page select-all reflects unchecked/mixed/checked state from actual selected IDs.
  • State which records select-all includes: visible page, loaded filtered result, or a remote query. Never silently turn a current-page checkbox into an all-corpus operation.
  • Distinguish row selection from opening details. View is a named button; a noninteractive row is not given misleading link/button/grid semantics.
  • Filtering and paging must not silently discard selections or a loaded detail. Show off-page selection counts and an explicit clear-all action. Destructive or remote batches require their own authorization, confirmation and per-record failure handling.
  • The local summary snapshots actual selected record titles. Clearing selection preserves that prior result; it is not a claim of durable backend saving.

Keyboard, focus and accessible relationships

  • Preserve native table elements and explicit scopes. Do not switch to role=grid merely because cells contain controls; grid behavior would require an independently implemented keyboard/editing model.
  • Tab reaches ordinary buttons and checkboxes; Space/Enter activate their native contracts. Native region scrolling provides comparison access without introducing cell-by-cell tab stops.
  • For overflow, name and focus the container separately from the table. containerRef is the scrolling div; Table ref remains the table. Explain horizontal scrolling visibly without replacing table structure.
  • Use restrained status text for changed result/selection scope. Avoid announcing every cell, forcing focus on each sort, or duplicating the whole table in a live region.
  • Keep changing rows keyed by stable IDs so sorting does not attach a checkbox or detail result to a different entity. Spoken assistive-technology table navigation requires a separate real-device audit.

Adaptive layout and density

  • Contain comparison overflow within the wrapper; the whole documentation/product page must continue to reflow at 320px. Keep caption, filter, paging, selection count and recovery outside hidden offscreen columns when practical.
  • Compact padding changes density, not target requirements. Leave interactive controls comfortably operable and do not encode a state by reducing text below the chosen readable type scale.
  • Do not hide important columns to make a table fit unless their information remains available through an explicit detail view. A card/list alternative must preserve names, values, relationships and controls.
  • Logical alignment supports RTL; numeric columns can use logical end alignment. Test actual native scroll direction and visible controls instead of assuming LTR scrollLeft signs.
  • Huge datasets need bounded fetching/virtualization with separately verified header, focus, selection and total semantics. This HTML wrapper renders all supplied rows and does not supply those capabilities.

Loading, empty, error and stale records

StateProduct policy
Initial loadingName the requested set and keep controls available as appropriate. Use a busy region/progress pattern; skeleton rows do not prove that records exist.
Loaded emptyDistinguish no records in the set from no matches under current filters. A filtered empty row spans all current columns and offers a reachable clear-filter action.
Failure / retryRetain useful filters, draft and last successful data. Label stale data and the failed scope; retry the actual request rather than silently fabricating replacements.
Partial / unavailableKeep missing fields distinct from zero, unreviewed from rejected, and permission denial from network error. Do not aggregate unseen rows as if they were verified.
Deletion / reconciliationReconcile by stable IDs with explicit policy. Selections outside a page are not deleted records; authorization and destructive lifetime are separate tasks.

Migration and verification

The old renderer emitted mt-table-wrapper while CSS defined mt-table-container, leaving the advertised horizontal containment absent. The wrapper now matches the renderer and has explicit native props/ref. Existing table ref and part semantics remain intact. The former prop reference also omitted exported TableFooter.

  • Run test:table, checked-source/token freshness, lint, shared UI/styleguide types, production build and complete route/fragment crawl. Verify actual sort order and aria-sort, mixed/current-page selection, stable selection across paging/filtering, opened content, summary scope, footer totals and empty recovery.
  • Measure overflow and keyboard/ref scrolling at narrow widths in both directions/themes. Check compact geometry, control targets, text contrast, focus and page reflow.
  • These tests cover this loaded fixture. Huge/remote datasets, consumer custom CSS, actual authorization and spoken assistive-technology behavior require separately scoped evidence.