Clara Whitfield
NegotiationChief Financial Officer · Cormorant Foods
Chicago, IL
About
- Deal value
- $76,000
- Owner
- Linh Vo
- Last activity
Activity
Email ·
Call ·
Meeting ·
Note ·
Stage change ·
Stage change ·
Patterns
The screens a revenue team lives in: a contact grid that really filters, sorts and edits in place, the record behind the focused row, the board the deal moves across, and the numbers all of it rolls up to.
Every demo on this page reads and writes one contact list. Drag a deal across the board and the funnel, the grid row and the record’s timeline move with it; close one from its Stage cell in the grid and the metrics move too. That is the point of the pattern, and the reason none of these previews are screenshots. The code samples split the same thing across the files a real app would have — one shared ./crm-domain module for the stage table, the owners, the formatters and the workspace’s region settings. There is no file of English strings any more: the package speaks English on its own, and one ComitorLocaleProvider at the root switches all of it.
Four StatCards, every figure computed from the contact list below rather than typed in. tone paints the icon chip only; the colour of the change line comes from polarity, not from the arrow — which is why “stalled over 10 days” going up is red and not green.
Open pipeline
$698.8K
+12.5%vs. last quarter
Closed won
$248K
+2 dealsthis quarter
Win rate
43%
-3.2 ptsvs. last quarter
Stalled deals
3
+2vs. last week
The centrepiece. A contact carries ten fields here, the questions a sales team asks of them stack up — open deals over $50,000 that nobody has touched in ten days — and most changes are made on the way past: a new owner, the next stage, one more tag. Paging through a read-only table and opening each record for that is slow, so the list is a DataGrid: conditions combine in the Filter and Sort menus, cells edit where they stand, and a column pastes in from a spreadsheet. PageHeader and PageContainer frame it. The stage chips are FilterChips that write the very filter the Filter menu lists — Stage has any of Proposal, Negotiation — with counts from TanStack’s faceting, which counts each stage under every filter except the stage one. The strip under the grid follows the focused row and draws what a cell cannot — faces, the stage’s colour, money, time — and so does the record in the next section. Ticking rows raises an ActionBar.
Everything here is live. Pick Proposal and Negotiation, then add “Deal value is greater than 50000” in the Filter menu; sort by Stage and it sorts by pipeline order; turn Location on from the View menu — it starts hidden; press Ctrl+F to find a cell. Move a deal to Closed won in its Stage cell and the metrics, the board and the funnel follow. Arrow down the grid and the strip keeps up, and so does the record below.
state hands it the filters and the sort, and onColumnFiltersChange and onSortingChange write them back — the chips have to reach them, and so could a URL or a saved view. The data is the page’s: onDataChange returns the whole list with a new object only for the rows an edit touched, and the page keeps it, logging any stage that moved — the same entry a drop on the board writes.tuan finds Hoàng Minh Tuấn (since 3.1.0; before that the grid kept accents). Ctrl+F finds and highlights cells without hiding any rows, by what a cell shows — an owner’s name, not their id — with the same folding.height bounds, and there is no page to reset when a filter changes. getRowId keys the selection by contact, so a ticked row stays ticked through sorting and filtering, and the header checkbox ticks every row the filters leave.sortingFn because its order is the pipeline’s, not the alphabet’s. Deal value is a number cell holding the bare number, so its header carries the unit: the workspace’s currency, not the language’s. Tags is a multi-select that folds its overflow into “+2” on its own and offers every tag in use, so one typed in the record below can be picked here.meta.readOnly keeps typing, pasting and clearing off that one column while the rest of the row stays editable. A date cell holds yyyy-MM-dd, so an accessorFn turns the timestamp into the day it fell on in the viewer’s time zone — the one the record’s timeline prints in — and the sort still reads the whole timestamp. The four open stages plus Last activity before a day ten days back is the Stalled deals card, as a list.open follows table.getSelectedRowModel(), and onOpenChange(false) calls table.resetRowSelection() — the ×, Escape and every action that does not call preventDefault() all land in that one handler. Assign owner only opens a menu. Delete hands over to a ConfirmDialog outside the bar, because the bar unmounts the moment the selection clears — and it is the same dialog the grid’s own delete opens (Ctrl+Backspace, or the context menu): onRowsDelete returns a promise the dialog settles, and the grid keeps the rows ticked until it does. It settles with true once the rows are deleted and false when they are kept: since 3.3.3 the grid reads the answer, and settling with nothing reads as a delete.<body>, so a keyboard user has to Tab through everything else to reach it. portalContainer points it at an empty <div> right after the grid: it still floats, and it is the next stop in the tab order, ahead of the strip’s Email and Call. anchor="container" centres it on that <div> — the grid’s width — rather than on a window that also holds the sidebar. The preview frame also sets contain: layout, which pins the fixed bar to the frame instead of your window and centres it there, so the preview leaves anchor at its default: measured in window coordinates, it would land off by the frame’s own offset. An app leaves out the contain and keeps the anchor.onRowAdd opens the dialog instead of appending an empty line, and returns a promise: the new row’s position once the dialog creates it, null if it is cancelled. The New contact button, the grid’s footer row and Shift+Enter all take that one path. The page appends the record, the list drops its filters and sort so the last row is on screen, and the grid scrolls there and focuses the name. A pasted block taller than the list asks before it grows the list, then onRowsAdd appends that many unassigned leads for the paste to fill — one call, not one dialog per row.+14155550142) whatever was typed, and MaskInput hands back the bare number as its second argument. Both are given the workspace’s region explicitly — defaultCountry and currency — because neither is the language’s to decide. Without a country, a number typed without its + is not converted at all.What the focused row opens into. The header is LetterAvatar plus StatusPill, and the quick actions are real mailto: and tel: links. On the left, the record’s own data: TagsInput for the free-form labels the team types — the grid’s Tags column reads the same array, and offers any tag typed here — and KeyValue for custom fields. On the right, the history, as a Timeline filtered by FilterChips. Moves, closes and new records land on top of it as they happen: move this deal on the board below, then come back. Click another row in the grid above, or arrow to it, and this panel follows.
Chief Financial Officer · Cormorant Foods
Chicago, IL
Email ·
Call ·
Meeting ·
Note ·
Stage change ·
Stage change ·
RelativeTime, the tags’ remove buttons (“Remove renewal”) and the custom fields’ (“Remove Seats”) all follow the nearest ComitorLocaleProvider — English here, because this site has none. The words the app wrote itself, like “Add field”, are the app’s to translate. The timeline’s own stamps do the same: TimelineTime formats nothing, it takes your words and a machine-readable dateTime, so the words come from Intl.DateTimeFormat with the locale that useComitorLocale() returns, in the viewer’s time zone — pinned, so the server and the browser print the same string. In an app that zone comes from the signed-in user’s profile: a time zone belongs to a person, not to a workspace.activeIndex on the timeline. That prop turns it into a progress track — completed, active, pending. An activity feed is a history, so every dot stays “pending” and takes the colour of its activity type instead, with the type also spelled out in words.key={contact.id} on the custom fields. The record remounts the editor per contact, so one record’s duplicate-name error never shows up on the next. trim keeps its default: the field shows the space in “Contract term” as it is typed, and the record only ever stores the trimmed text.RelativeTime on this page gets now={NOW} — a fixed clock, so the sample data never drifts and the server and the browser render the same string. The samples leave it out: a live record is measured against the real clock.Kanban, with the stages as columns and the deals as cards. Drag a deal by its handle — with a mouse, with a finger, or from the keyboard: Space lifts it, the arrow keys carry it between stages, Space drops it and Escape puts it back where it was. The column counts and totals, the grid row, the funnel and the metric row all move while the card is still in the air, because Kanban writes every drag-over straight through onValueChange. The stage change itself is logged once, on the drop — open the record above to see it.
Foundry Labs
$15,000
Farid Rahman
Lumen Studios
$9,800
Linh Phạm
Quarry Motors
$14,500
Quang Trần
Umbra Security
$16,500
Umut Demir
Brightline Media
$22,500
Bao Le
Granite Freight
$27,500
Grace Mbeki
Kestrel Energy
$34,000
Karin Dahl
Orchid Hotels
$25,500
Oliver Grant
Summit Textiles
$29,000
Sam Whitaker
Yellowstone Tools
$24,000
Yuki Tanaka
Aurora Logistics
$48,000
Alice Nguyen
Delta Shipworks
$31,000
Daniel Osei
Ironwood Bank
$58,000
Isabel Moreau
Riverbend Farms
$39,500
Rosa Iglesias
Zenith Optics
$43,000
Zara Ahmed
Cormorant Foods
$76,000
Clara Whitfield
Harbor Health
$64,000
Hoàng Minh Tuấn
Northwind Grain
$53,000
Nadia Haddad
Tidewater Marine
$68,000
Tara Sullivan
Everest Analytics
$92,000
Elena Vasquez
Meridian Care
$71,000
Marcus Reid
Vantage Aero
$85,000
Vera Lindqvist
Juniper Retail
$12,000
James Okafor
Pinnacle Legal
$18,000
Priya Raman
Westford Print
$11,000
Wesley Cho
Alder & Co.
$8,500
Adrian Alder
Record<column, card[]>; the page has one flat contact list. The board builds the record with useMemo, and onValueChange writes the stage and the order back. The column order comes from sortByStatusPriority(DEAL_STAGES), so it cannot drift from the grid’s Stage column — and the closed stages are columns too, so winning a deal is one drop.KanbanItemHandle sets touch-none itself, so a finger on the handle lifts the card instead of scrolling the page.labels are functions that receive the position and that key, so the app looks up the names — in a useMemo over the live data, because a card’s stage changes while it is being dragged. The price: these sentences are now the app’s, so a multilingual app translates them itself.onValueChange.Each bar counts the contacts currently at that stage or further along; closed-lost records leave the chart. Progress draws the bar, which is worth more than it looks: the track carries a border so 0% is still a visible control, and Radix supplies role="progressbar" with a real aria-valuenow. The fill is the brand’s ink step — in the default palette’s light mode that is the gold fill itself, 1.86:1 on white — so the counts written beside each bar, not the bar, carry the figures.
The work attached to the relationships: Item rows with a real Checkbox, priority as a dot-variant StatusPill so it does not fight the stage chips for attention, and a due date that turns text-destructive-ink once it is past. Tick one: it drops to the bottom instead of leaving a dead row at the top.
5 follow-ups still open
Do
StatusConfig array and let the pill, the chips, the board’s columns and the sort comparator all read it.getRowId, so a selection is keyed by record id and survives sorting and filtering.meta.readOnly, the way Last activity is.defaultCountry on PhoneInput, currency on MaskInput, both from the workspace settings.Don't
DataGrid already has filters and sorts that combine, editing in the cell, the clipboard, pinning, column visibility, find and virtualisation.polarity.labels to translate. The provider switches every packaged string at once, screen-reader text included; labels is for wording only your domain knows — the unit in “3 contacts selected”, a deal’s name in a drag announcement.role="grid" with aria-rowcount. Each column header opens a menu — sort, pin, hide — and carries aria-sort; a ticked row carries aria-selected.role="status" regions, so each change announces itself without moving focus — the bar has already gone by the time its action lands.labels, and after a drop focus returns to the card’s handle in its new column.PhoneInput is a group of two controls. FormField’s label reaches the number field (the spread goes on PhoneInputField), so the group gets its own aria-label; the country button names its value (“Country: United States (+1)”).TagsInput, Backspace in the empty box highlights the last tag and a second Backspace removes it; each tag’s remove button is named (“Remove renewal”). Moving between tags with ← and → is not announced — a known limit of the headless core.ComitorLocaleProvider. The board’s drag announcements are the deliberate exception: they are the app’s own sentences, because only the app knows a deal’s name.Progress bar carries an aria-label naming the stage and the percentage.LetterAvatar always renders the name in sr-only text, so mark it aria-hidden when the name is already visible beside it.StatusConfig contract the stage table is built on.