Skip to content

Design: Rich Cell Data Types

Status: Draft — for review Created: 2026-05-21 Repository: ux-prototype (build), arda-frontend-app (vendor page consumer) Parent: Phase 2: Standardized AG Grid Resolves: Phase 2 Open Question #7 (cell editor architecture) and the Grouped/composite columns section.

The vendor grid renders two “rich” columns today — Role (a list of tokens like Vendor, Carrier) and Order Method (a single token like Online). Both are currently hand-rolled as inline cellRenderer functions on the column def. That works for display, but a cell renderer is cosmetic only: AG Grid runs sorting, filtering, grouping, clipboard, fill-handle, and export against the underlying value, not the rendered DOM. So the hand-rolled pills quietly break copy/paste, set-filtering, and CSV/Excel export of those columns — and every new rich column (color, image, address) re-introduces the same gap in a slightly different way.

This design adopts AG Grid’s cell data type mechanism (cellDataType + dataTypeDefinitions) as the single place where a rich cell type is defined. A data type owns the value ↔ string round trip (valueFormatter / valueParser), the filter/group key (keyCreator), and — through a referenced column type — the renderer and editor. Because copy, cut, paste, bulk paste, fill-down, range copy-down, and export are all driven by that same formatter/parser pair, defining it once makes every one of those behaviors consistent and automatic for every column of that type.

The scope here is the tokens family (Role and Order Method), built on a shared TokenList presentational molecule and the existing typeahead cell editors. A bonus section shows the identical architecture scaling to a composite Address type — many inputs while editing, one line when rendered. Image columns — the thumbnail renderer and the upload/preview editor — follow the same recipe: the existing grid-image.tsx is another hand-rolled one-off with the same copy/filter/export gap, and it migrates to an image data type in a later slice, as does color. The sequencing for these and other future types is captured in the pre-defined data type roadmap below.

#DecisionChosen Option
DQ-001Mechanism for reusable rich cellscellDataType + dataTypeDefinitions (not a per-column renderer, not a partial-ColDef factory)
DQ-002Where the value↔string contract livesIn the data type: valueFormatter (value→string) + valueParser (string→value)
DQ-003How renderer + editor attach to a data typeVia a named columnTypes bundle the data type references (carries cellRenderer, cellEditor, keyCreator)
DQ-004Single-select vs multi-select tokensOne tokens family; multi = string[], single = string; same renderer, different editor + parser arity
DQ-005Composite columns (Address)An object data type with a popup composite editor and a single-line renderer — same architecture as tokens
DQ-006Bulk paste / fill / export wiringRely on the data type’s formatter/parser by default; override per-grid only with processCell*FromClipboard for validation feedback

The alternatives considered for each decision, with trade-offs and rationale, are recorded in the companion Decision Log; review questions from PR #99 are tracked there as Round 2 (DQ-007 – DQ-009).


Background: how AG Grid wires features (the key insight)

Section titled “Background: how AG Grid wires features (the key insight)”

Before the decision, the one fact that drives everything: renderers are cosmetic, values are functional. From the AG Grid docs (v34.3.1):

The raw values, and not the result of cell renderer will get used [for export]. Value Getters will be used. Cell Renderers will NOT be used. Cell Formatters will be used by default.

and

Sorting and Filtering are NOT impacted by [rendering] … done before rendering.

So whatever makes a rich column “work” for clipboard/filter/export cannot live in the renderer. It must live on the value-level hooks. Each AG Grid feature maps to exactly one hook:

FeatureHook it usesDirection
Copy / Cut / range copy / CSV / Excel / clipboardvalueFormatter (useValueFormatterForExport, default true)value → string
Paste / bulk paste / fill-handle / copy-range-downvalueParser (useValueParserForImport, default true)string → value
Set filter / grouping / in-editor searchkeyCreatorvalue → key string
Sort / quick filterthe value (or valueFormatter)
DisplaycellRenderervalue → DOM (cosmetic)
EditcellEditor

The hand-rolled vendor renderers supply only the last two. That is why Role’s set filter and CSV export are broken today, and why the fix is not “make a nicer renderer” but “define the value hooks once, in a place every rich column shares.”

The table above also settles a recurring architecture question: do we push values into AG Grid, or does AG Grid pull them from us? AG Grid is a pull (inversion-of-control) system, and the data-type design leans into that. You push the source value into the grid exactly once — when you set rowData. After that the grid owns the row model and pulls each representation by calling the hooks above at the moment it needs them: cellRenderer to paint, valueFormatter to copy/export, valueParser to paste/fill, keyCreator to filter/group.

Pull is the correct paradigm because one source value (["Vendor","Carrier"]) must serve many consumers, each wanting a different representation at a different time. Pull transforms the value on demand and stays consistent everywhere. Pushing pre-computed representations instead — storing the display string, the filter key, and the export text next to the value — forces you to anticipate every consumer and resync them on every edit. That is exactly the drift this design removes: the old hand-rolled renderer pushed cosmetic DOM but never supplied the value hooks, so copy, filter, and export silently broke.

The only legitimate push is the initial rowData seed. Imperative writes after that — mutating a row and calling api.refreshCells(...) — are an escape hatch, acceptable only for a derived display field (e.g. a favicon recomputed from other cells). Such a field sits outside the value round trip, so it will not participate in copy/paste/export; if it ever needs to, promote it to a pull-based valueGetter or its own data type rather than keeping the imperative refresh.


Three mechanisms can package a rich cell. They differ in how much of the table above they cover for free, consistently, across many columns.

MechanismWhat it bundlesCopy/paste/bulk/fill/export consistencyVerdict
Per-column cellRenderer/cellEditor (today)renderer + editor on each column❌ none — each column must hand-wire formatter/parser/keyCreator or silently breakRejected — this is the current drift
Partial-ColDef factory (createTokenColumn(...))renderer + editor + keyCreator + formatter, returned as a ColDef fragment⚠️ possible, but the value-round-trip wiring is re-applied per factory; nothing forces colors/images/address factories to stay consistentRejected as the spine — useful only as sugar
cellDataType + dataTypeDefinitionsformatter + parser + keyCreator + (via columnTypes) renderer + editor, registered once and applied by name✅ automatic — every column with cellDataType: 'x' inherits the entire round trip identicallyChosen (DQ-001)

The deciding factor is the explicit Phase 2 goal: copy/paste, bulk copy/paste, and bulk edits should behave the same way across every rich data type (tokens, colors, images, address). Only the data-type mechanism makes the value↔string round trip the unit of reuse — which is exactly the unit those features run on. A factory can produce a correct column, but it cannot guarantee that the next engineer’s createColorColumn parses and formats on the same contract; the data type registry can, because the grid resolves behavior from the type name.

We still keep a thin factory (createTokenDataType) — but it produces a data-type definition, not a loose ColDef. Sugar on top of the spine, not instead of it.


Three layers, each independently testable. Presentation is reusable outside the grid; the grid-specific contract is concentrated in the data-type registry.

The component view below shows the dependency direction: the vendor page selects data types by name; the registry binds each type’s value hooks to a presentational TokenList and a typeahead editor; the DataGrid registers the types and the Enterprise clipboard/cell-selection modules with AG Grid.

PlantUML diagram

LayerLives inKnows about the grid?Examples
Presentationcanary/molecules/token-list/, canary/atoms/badge/NoTokenList, Badge
Editors (adapters)canary/molecules/typeahead-input/The cellEditorMode contract onlycreateMultiSelectCellEditor, createTypeaheadCellEditor
Data-type registrycanary/molecules/data-grid/cell-data-types/Yes — owns the AG Grid contractcreateTokenDataType, dataTypeDefinitions, columnTypes

The editor-adapter layer is already documented in AG Grid Cell Editors from Inputs; this design adds the layer above it.


Worked example 1 — Role (multi-select tokens)

Section titled “Worked example 1 — Role (multi-select tokens)”

Role is string[] — e.g. ["Vendor", "Carrier"]. It renders as a row of filled pills and edits as a multi-select typeahead with checkboxes.

In read mode the cell shows one pill per role, collapsing to +N more when the row is too narrow; double-click (or Enter) opens the multi-select editor with the current roles pre-checked and a filter box.

PlantUML diagram

PlantUML diagram

The data type owns four things; the renderer and editor are bound through a named column type.

canary/molecules/data-grid/cell-data-types/token-data-type.tsx
import type {
ColTypeDef,
DataTypeDefinition,
KeyCreatorParams,
ValueFormatterParams,
ValueParserParams,
} from 'ag-grid-community';
import type { CustomCellRendererProps } from 'ag-grid-react';
import { TokenList } from '@/components/canary/molecules/token-list/token-list';
import {
createMultiSelectCellEditor,
type MultiSelectCellEditorConfig,
} from '@/components/canary/molecules/typeahead-input/multiselect-cell-editor';
import {
createTypeaheadCellEditor,
type TypeaheadCellEditorConfig,
} from '@/components/canary/molecules/typeahead-input/typeahead-cell-editor';
/**
* The entire typeahead/multiselect surface passes straight through `editor` —
* `lookup` (async fn OR static list), `maxResults`, `placeholder`, plus
* `defaultOne` (multi) and `allowCreate` + `clearOnFocus` (single). The data
* type adds the value round trip; it does not restrict the editor.
*/
export type TokenDataTypeConfig =
| {
multiple: true;
editor: MultiSelectCellEditorConfig;
/** Badge variant: filled (multi) or outline (single), by convention. */
variant?: 'secondary' | 'outline';
/** Closed list for paste validation. Defaults to a static `editor.lookup`. */
validValues?: string[];
}
| {
multiple: false;
editor: TypeaheadCellEditorConfig;
variant?: 'secondary' | 'outline';
validValues?: string[];
};
/** value -> display string. Array joins with ", "; scalar passes through. */
const toText = (value: unknown): string =>
Array.isArray(value) ? value.join(', ') : value == null ? '' : String(value);
export function createTokenDataType(config: TokenDataTypeConfig): {
dataType: DataTypeDefinition;
columnType: ColTypeDef;
} {
// Full passthrough — every typeahead/multiselect prop is honored.
const editor = config.multiple
? createMultiSelectCellEditor(config.editor)
: createTypeaheadCellEditor(config.editor);
// Paste/fill validation list: an explicit `validValues`, else a static
// `lookup` array. With an async `lookup` function there is no synchronous
// list, so paste shape-parses and option validation is deferred (see note).
const closedList: string[] | undefined =
config.validValues ??
(Array.isArray(config.editor.lookup)
? config.editor.lookup.map((o) => (typeof o === 'string' ? o : o.value))
: undefined);
// string -> value. When a closed list exists, junk is rejected.
const parse = (text: string | null | undefined): string[] | string | null => {
if (!text) return null;
let parts = String(text).split(',').map((s) => s.trim()).filter(Boolean);
if (closedList) parts = parts.filter((p) => closedList.includes(p));
if (parts.length === 0) return null;
return config.multiple ? parts : parts[0]!;
};
return {
// Renderer + editor + filter/group key — bound to the data type by name.
columnType: {
cellRenderer: (p: CustomCellRendererProps) => {
const values = Array.isArray(p.value) ? p.value : p.value ? [p.value] : [];
return <TokenList values={values} variant={config.variant} />;
},
cellEditor: editor,
cellEditorPopup: false, // inline; the dropdown is portaled by the input itself
keyCreator: (p: KeyCreatorParams) => toText(p.value),
},
// The value round trip — drives copy/paste/fill/export for free.
dataType: {
baseDataType: 'object',
extendsDataType: 'object',
valueFormatter: (p: ValueFormatterParams) => toText(p.value),
valueParser: (p: ValueParserParams) => parse(p.newValue),
// `columnTypes` (the binding name) is assigned at registration — see below.
},
};
}

Registration names the two halves so the data type can reference its column type. Consumers only write registry entries:

app/vendors/vendor-grid-types.ts
import { createTokenDataType } from '@arda-cards/design-system/canary';
const ROLE_OPTIONS = ['Vendor', 'Customer', 'Carrier', 'Operator', 'Other'];
const ORDER_METHOD_OPTIONS = ['Online', 'Purchase order', 'Email', 'Phone', 'In store', 'RFQ', 'Production', '3rd party'];
const roles = createTokenDataType({
multiple: true,
editor: { lookup: ROLE_OPTIONS, placeholder: 'Select roles…', defaultOne: true },
variant: 'secondary',
});
const orderMethod = createTokenDataType({
multiple: false,
editor: {
lookup: ORDER_METHOD_OPTIONS,
placeholder: 'Order method…',
maxResults: ORDER_METHOD_OPTIONS.length, // show the whole list
clearOnFocus: true,
},
variant: 'outline',
});
export const columnTypes = {
rolesColType: roles.columnType,
orderMethodColType: orderMethod.columnType,
};
export const dataTypeDefinitions = {
roles: { ...roles.dataType, columnTypes: 'rolesColType' },
orderMethod: { ...orderMethod.dataType, columnTypes: 'orderMethodColType' },
};

The vendor column defs collapse to a single property each — no inline renderer, no per-column editor, no missing keyCreator:

const columnDefs: ColDef<VendorRow>[] = [
{ field: 'name', headerName: 'Name', flex: 1, minWidth: 200, cellRenderer: VendorNameCell },
{ field: 'roles', headerName: 'Role', width: 200, editable: true, cellDataType: 'roles' },
{ field: 'orderMethod', headerName: 'Order Method', width: 180, editable: true, cellDataType: 'orderMethod' },
// …
];

The editor surface passes through untouched

Section titled “The editor surface passes through untouched”

The data type holds whatever createTypeaheadCellEditor / createMultiSelectCellEditor produce, so the entire input API is available via editor — nothing is lost or hardcoded:

PropSingle (createTypeaheadCellEditor)Multi (createMultiSelectCellEditor)
lookup — async fn or static list
maxResults
placeholder
clearOnFocus
allowCreate
defaultOne

An async lookup is just another editor.lookup — the round trip is unchanged:

const supplier = createTokenDataType({
multiple: false,
editor: { lookup: lookupSuppliers, allowCreate: true, maxResults: 20, clearOnFocus: true },
// no static list -> paste shape-parses; option validation deferred (see below)
});

The one seam — paste validation vs. lookup. The editor can resolve options asynchronously, but the data type’s valueParser runs synchronously during paste and fill. So:

  • Static lookup (or explicit validValues) → the parser validates against the closed list and rejects junk, matching the Phase 2 “invalid input” spec.
  • Async lookup (function) → the parser can only shape-parse (split/trim); option validation is deferred to commit (or a grid-level processCellFromClipboard). The editor still does full async lookup — only mid-paste validation can’t await it.

valueFormatter and keyCreator are value-only, so they are unaffected by which form lookup takes.


Worked example 2 — Order Method (single-select token)

Section titled “Worked example 2 — Order Method (single-select token)”

Order Method is a single string (the vendor’s primary order method). It renders as one outline pill, or when empty, and edits as a single typeahead that shows the full list on focus and clears the filter on focus (clearOnFocus).

The two states below mirror example 1 with multiple: false: one pill in read mode, a single-choice list in edit mode. The renderer is the same TokenList (given a one-element array); only the editor and parser arity differ — the whole point of DQ-004.

PlantUML diagram

PlantUML diagram

No new code is needed beyond the registry entry already shown — multiple: false selects the typeahead editor, makes the parser return a scalar, and renders the single pill via the shared TokenList. This is the consistency payoff: Role and Order Method are one factory called twice.

The shared presentational molecule:

canary/molecules/token-list/token-list.tsx
import { Badge } from '@/components/canary/atoms/badge/badge';
import { cn } from '@/types/canary/utilities/utils';
export interface TokenListProps {
values: string[];
variant?: 'secondary' | 'outline';
className?: string;
}
/**
* Read-mode display for token cells. One Badge per value, collapsing to
* "+N more" when the row is too narrow (dynamic overflow reuses the
* ResizeObserver measurer pattern from OverflowToolbar / MultiSelectTypeaheadInput).
*/
export function TokenList({ values, variant = 'secondary', className }: TokenListProps) {
if (values.length === 0) {
return <span className="text-muted-foreground text-xs"></span>;
}
return (
<div className={cn('flex h-full items-center gap-1 overflow-hidden', className)}>
{values.map((v) => (
<Badge key={v} variant={variant} className="shrink-0 whitespace-nowrap">
{v}
</Badge>
))}
</div>
);
}

Behavioral design — the round-trip flows

Section titled “Behavioral design — the round-trip flows”

These flows are what the data type buys us. None of them have any column-specific code; they all run through valueFormatter and valueParser.

Copying a Role cell does not copy the pills’ DOM; AG Grid asks the data type’s valueFormatter for a string and puts that on the clipboard.

PlantUML diagram

Pasting (one cell or a whole selected range) sends each clipboard string through the data type’s valueParser. Invalid values are rejected by the parser and can trigger validation feedback (Phase 2 spec: red ring + “invalid input” alert).

PlantUML diagram

The salt mock shows a 3-row range selected in the Role column being pasted into (»…« marks the selection); the same parsed value lands in every selected cell, while the unselected row is untouched.

PlantUML diagram

Dragging the fill handle from one cell over a range copies the source value down, re-parsing through the same contract. Configured by enabling cellSelection.handle.mode: 'fill'.

PlantUML diagram

PlantUML diagram

Selecting a range, opening the editor on one cell, and pressing Ctrl+Enter applies the chosen value to every editable cell in the range — no extra code; the editor commits a value and AG Grid spreads it.


The canary DataGrid currently registers only AllCommunityModule + RichSelectModule and does not forward data-type or cell-selection config. Three small additions make the whole family work.

// data-grid.tsx — module registration
import { ClipboardModule, CellSelectionModule, RichSelectModule } from 'ag-grid-enterprise';
ModuleRegistry.registerModules([
AllCommunityModule,
RichSelectModule,
ClipboardModule, // copy / cut / paste (Enterprise)
CellSelectionModule, // range selection + fill handle (Enterprise)
]);
// data-grid.tsx — new optional props on DataGridStaticConfig
import type { CellSelectionOptions, ColTypeDef, DataTypeDefinition } from 'ag-grid-community';
/** Custom cell data type registry (e.g. tokens, address). */
dataTypeDefinitions?: Record<string, DataTypeDefinition>;
/** Named column-type bundles referenced by data types or `type`. */
columnTypes?: Record<string, ColTypeDef>;
/** Range selection + fill handle. `{ handle: { mode: 'fill' } }` enables fill-down. */
cellSelection?: boolean | CellSelectionOptions;
// data-grid.tsx — forwarded to AgGridReact (omit-when-undefined per exactOptionalPropertyTypes)
<AgGridMemo
/* …existing props… */
{...(dataTypeDefinitions ? { dataTypeDefinitions } : {})}
{...(columnTypes ? { columnTypes } : {})}
{...(cellSelection !== undefined ? { cellSelection } : {})}
/>

Consumer wiring (the vendor page):

import { columnTypes, dataTypeDefinitions } from './vendor-grid-types';
<DataGrid<VendorRow>
rowData={rows}
columnDefs={columnDefs}
columnTypes={columnTypes}
dataTypeDefinitions={dataTypeDefinitions}
cellSelection={{ handle: { mode: 'fill' } }}
editable
/>

Validation feedback on paste (optional, per-grid)

Section titled “Validation feedback on paste (optional, per-grid)”

By default the parser silently drops invalid pasted values. To match the Phase 2 spec — flash the cell red and show an “invalid input” alert — add a grid-level processCellFromClipboard that consults the column’s data type and vetoes the value when it fails to parse. This is the only place clipboard behavior needs custom code, and it is generic across all data types.

// data-grid.tsx — optional, enabled when a consumer passes onInvalidPaste
import type { ProcessCellForExportParams } from 'ag-grid-community';
const processCellFromClipboard = (p: ProcessCellForExportParams) => {
const def = p.column.getColDef();
const parser = /* resolve dataTypeDefinitions[def.cellDataType]?.valueParser */;
if (!parser) return p.value; // plain column — default behavior
const parsed = parser({ newValue: p.value, column: p.column, /* … */ } as never);
if (parsed == null && p.value?.trim()) {
flashCellInvalid(p.api, p.node, p.column); // red ring for ~1s
onInvalidPaste?.(p.value); // consumer shows the alert banner
return p.node?.data?.[def.field as keyof typeof p.node.data]; // keep old value
}
return parsed;
};

Bonus — Address (a composite object data type)

Section titled “Bonus — Address (a composite object data type)”

Address is the proof that this architecture is not token-specific. An address is structured data — { street, city, state, zip, country } — that should render as one line but edit as several inputs, and still copy, paste, fill, and export through the same contract.

It renders as a single formatted line; double-click opens a small multi-field form (a popup editor, because the form grows beyond the cell), and committing writes the structured object back.

One field, not many. This Address example assumes the row stores Address as a single field whose value is the { street, city, state, zip, country } object — that is what lets it be a cellDataType. If instead the address lives as several sibling fields on the row (street, city, state, …), the gather/scatter across fields cannot be expressed by a data type — DataTypeDefinition has no valueGetter/valueSetter — and must be a ColDef factory that owns valueGetter (gather) and valueSetter (scatter). The two cases look alike but use different mechanisms; the multi-field variant is combined-column.tsx in ux-prototype. The composite object’s value contract (valueFormatter/valueParser/keyCreator/ renderer/editor) is identical across the two, so the factory and a future createCompositeObjectDataType can share that half.

The same split answers joint editing of units — a quantity edited together with its unit of measure. Stored as one field ({ amount: 12, unit: 'kg' }), it is a composite data type exactly like Address: one popup editor, cross-field validation (the unit constrains precision), one formatted line (12 kg) for copy/filter/export. Stored as sibling amount/unit row fields, it is the combined-column.tsx ColDef-factory variant. See DQ-008 in the Decision Log.

PlantUML diagram

PlantUML diagram

The data type is structurally identical to createTokenDataType — only the formatter/parser shapes and the editor change. Note cellEditorPopup: true, since a multi-field form must escape the cell bounds while editing.

export interface Address {
street: string; city: string; state: string; zip: string; country: string;
}
const formatAddress = (a: Address | null | undefined): string =>
a ? [a.street, a.city, `${a.state} ${a.zip}`.trim(), a.country].filter(Boolean).join(', ') : '';
// Round-trips a single-line address; high-fidelity field-by-field copy can use a
// stricter delimiter or processDataFromClipboard (see Out of Scope).
const parseAddress = (text: string | null | undefined): Address | null => {
if (!text) return null;
const [street = '', city = '', stateZip = '', country = ''] = text.split(',').map((s) => s.trim());
const [state = '', zip = ''] = stateZip.split(/\s+/);
return { street, city, state, zip, country };
};
export const addressColumnType: ColTypeDef = {
cellRenderer: (p: CustomCellRendererProps) => <span className="truncate">{formatAddress(p.value)}</span>,
cellEditor: createAddressCellEditor({ /* state + country lookups, validation */ }),
cellEditorPopup: true, // the form grows beyond the cell
keyCreator: (p: KeyCreatorParams) => formatAddress(p.value),
};
export const addressDataType: DataTypeDefinition = {
baseDataType: 'object',
extendsDataType: 'object',
valueFormatter: (p) => formatAddress(p.value),
valueParser: (p) => parseAddress(p.newValue),
columnTypes: 'addressColType',
};

What each grid feature does with an Address, for free:

FeatureBehavior
Copyone line: 123 Main St, Austin, TX 78701, USA (valueFormatter)
Paste / bulk pasteparse the line back into the object, applied to every cell in range (valueParser)
Fill-downcopy the whole address object down the range
Bulk editopen the form on one cell, Ctrl+Enter to apply the object across the range
Exportthe formatted single line (CSV/Excel)
Set filter / groupbucket by the formatted address (or change keyCreator to group by city/state)
Sortby the formatted string (or a comparator on a chosen field)

The composite editor manages its own internal layout, focus order, and cross-field validation (e.g. country constrains the state list) — exactly the “form group / composite input” need called out in the Phase 2 plan. Because the editor satisfies the same value + onValueChange + onCommit contract as a single input, it slots into the same adapter described in AG Grid Cell Editors from Inputs.


The design system will ship a curated, growing set of pre-defined cell data types, added as they are needed and mature. AG Grid already provides five built-in data types — the library’s job for those is to adopt and configure them (formatting, locale, editors) rather than build from scratch; the rich types below them are built on the architecture in this design. Priority order:

#Data typeKindStatus
1TextAG Grid built-in (text)Available — default
2Number (Decimal)Built-in (number), configured for precision/localeAvailable — needs config pass
3BooleanBuilt-in (boolean, checkbox renderer/editor)Available — needs config pass
4DateBuilt-in (date / dateString)Available — needs config pass
5Tokens — single & multiCustom (this design, first slice)In design (this document)
6Currency / MoneyCustom — decimal + currency code, locale-formattedPlanned
7AddressCustom composite (bonus section of this design)Designed
8Quantity + UnitCustom composite — joint editing of amount and unit of measure (DQ-008)Planned
9ImageCustom — thumbnail renderer + upload/preview editor; migrates grid-image.tsx (DQ-007)Planned
10ColorCustom — swatch renderer + picker editorPlanned

Each slice picks the next entry; a type is “shipped” when it has the full value round trip (formatter, parser, keyCreator), a renderer, an editor, stories, and round-trip unit tests per the Testing Strategy.


FilePathPurpose
token-list.tsxcanary/molecules/token-list/Presentational pill list (composes Badge); dynamic +N more overflow
token-list.test.tsx / .stories.tsx / .mdxsameTests, stories, prop docs
token-data-type.tsxcanary/molecules/data-grid/cell-data-types/createTokenDataType factory (renderer + editor + formatter + parser + keyCreator)
token-data-type.test.tsxsameRound-trip unit tests (format/parse/keyCreator)
cell-data-types/index.tssameBarrel + canary export
FileChange
canary/molecules/data-grid/data-grid.tsxRegister ClipboardModule + CellSelectionModule; add dataTypeDefinitions, columnTypes, cellSelection passthrough props; optional processCellFromClipboard + onInvalidPaste
src/canary.tsExport TokenList, createTokenDataType, related types
arda-frontend-app/src/app/vendors/page.tsxReplace inline Role/Order Method renderers + editors with cellDataType; add vendor-grid-types.ts; pass registries + cellSelection to DataGrid
data-grid.stories.tsxAdd a WithCellDataTypes story demonstrating copy/paste/fill on tokens
  • Color and image data types. The existing grid-image.tsx is a hand-rolled one-off with the same gap; migrating it (and adding color) follows the same recipe but is a separate slice.
  • High-fidelity structured clipboard for Address (field-preserving paste from external sources). The single-line round trip is in scope; richer transfer via processDataFromClipboard is future work.
  • Address verification API and full cross-field validation rules.
  • Undo/redo (undoRedoCellEditing) — tracked separately in Phase 2.

TestTargetValidates
format/parse round trip (multi)createTokenDataType({multiple:true})["Vendor","Carrier"] ⇄ "Vendor, Carrier"
format/parse round trip (single)createTokenDataType({multiple:false})"Online" ⇄ "Online"; empty ⇒ null
parser rejects invalid optionstoken data type"Vendor, Bogus"["Vendor"]; "Bogus"null
keyCreator outputtoken data typestable, human-readable key for set filter/grouping
TokenList renderTokenListone badge per value; when empty; variant applied
Address format/parseformatAddress / parseAddressobject ⇄ single line

Story / VRT (layout-dependent — jsdom has no layout engine)

Section titled “Story / VRT (layout-dependent — jsdom has no layout engine)”
StoryValidates
WithCellDataTypesedit lifecycle, +N more overflow, no vertical jump on edit
Copy → paste across a rangebulk paste applies parsed value to all cells
Fill-handle drag-downsource value extends down the range
Invalid pastered flash + alert; old value retained

API tests: not applicable (front-end only).



Copyright: (c) Arda Systems 2025-2026, All rights reserved