Ordered TanStack column definitions for the Product row type.
const columns = TableColumns();
useReactTable({ columns, data, ... });
// columns.map(c => c.id) →
// ["expander", "title", "supplier", "country", "shipping",
// "availability", "description", "price", "quantity", "uom",
// "unitPrice", "priceTrend", "sds", "specs", "coa", "cas", "pubchem", "formula",
// "moleweight", "purity", "concentration"]
// columns.filter(c => c.meta?.drawer).map(c => c.id) →
// ["supplier", "country", "shipping", "availability", "price"]
export default function TableColumns(): ColumnDef<Product, unknown>[] {
return [
{
id: 'expander',
header: () => null,
cell: ({ row }: ProductRow) => {
// `getCanExpand` (backed by hasExpandableDetail) already gates whether
// there's anything to show in the panel — image, variants, or details.
return row.getCanExpand() ? (
<button
{...{
onClick: row.getToggleExpandedHandler(),
style: { cursor: 'pointer' },
}}
className={styles['svg-button-icon']}
>
{row.getIsExpanded() ? (
<ArrowDropDownIcon fontSize="small" />
) : (
<ArrowRightIcon fontSize="small" />
)}
</button>
) : null;
},
enableHiding: false,
// Fixed width sized to fit the 20px toggle button (.svg-button-icon) so
// the expand/collapse chevron isn't clipped under table-layout: fixed.
minSize: 24,
maxSize: 24,
size: 24,
enableSorting: false,
enableColumnFilter: false,
enableResizing: false,
},
{
id: 'title',
accessorKey: 'title',
header: i18n('column_title'),
cell: ({ row }: ProductRow) => {
// Indent variant rows so the hierarchy is visually obvious — 16px per
// depth level, matching the chevron column width.
const indent = row.depth > 0 ? row.depth * 16 : 0;
return (
<span style={{ paddingLeft: indent, display: 'inline-block' }}>
<Link
history={{
type: 'product',
data: omit(row.original, 'variants'),
}}
href={row.original.permalink ?? row.original.url}
>
{row.original.title}
</Link>
</span>
);
},
enableHiding: false,
filterFn: 'includeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_title'),
filterVariant: 'text',
// Titles are long; let the column grow wider than most (to this cap),
// then word-wrap within that width.
autoSizeMax: 240,
style: {
textAlign: 'left',
},
},
},
{
id: 'supplier',
header: i18n('column_supplier'),
accessorKey: 'supplier',
cell: (info) => info.getValue(),
filterFn: 'multiSelect',
meta: {
filterPlaceholder: i18n('filter_placeholder_supplier'),
filterVariant: 'select',
style: {
textAlign: 'left',
},
drawer: {
label: i18n('drawer_supplier_label'),
widget: 'autocompleteStrings',
options: SupplierFactory.supplierList(),
optionLabels: SupplierFactory.supplierDisplayNames(),
emptyHelperText: i18n('drawer_supplier_empty_helper'),
placeholder: i18n('drawer_supplier_placeholder'),
bind: { kind: 'selectedSuppliers' },
},
},
},
{
id: 'country',
header: i18n('column_country'),
accessorKey: 'supplierCountry',
cell: ({ row }: ProductRow) => {
const country = row.original.supplierCountry;
if (!country) return null;
if (!hasFlag(country)) return country;
const countryName = getCountryName(country) ?? country;
return (
<CountryFlagTooltip title={countryName} placement="top">
<span>{getUnicodeFlagIcon(country)}</span>
</CountryFlagTooltip>
);
},
filterFn: 'multiSelect',
meta: {
filterPlaceholder: i18n('filter_placeholder_country'),
filterVariant: 'select',
style: {
textAlign: 'center',
},
renderSelectOption: (code) => (hasFlag(code) ? getUnicodeFlagIcon(code) : code),
drawer: {
label: i18n('drawer_country_label'),
widget: 'autocompleteObjects',
options: SUPPLIER_COUNTRY_OPTIONS,
emptyHelperText: i18n('drawer_country_empty_helper'),
placeholder: i18n('drawer_country_placeholder'),
bind: { kind: 'searchFilters', key: 'country' },
},
},
},
{
id: 'shipping',
header: i18n('column_shipping'),
accessorKey: 'supplierShipping',
cell: ({ row }: ProductRow) => {
const shipping = row.original.supplierShipping;
return shipping ? shippingLabel(shipping) : null;
},
filterFn: 'multiSelect',
meta: {
filterPlaceholder: i18n('filter_placeholder_shipping'),
filterVariant: 'select',
renderSelectOption: (value) => shippingLabel(value),
drawer: {
label: i18n('drawer_shipping_label'),
widget: 'chips',
options: SHIPPING_OPTIONS,
formatChipLabel: (option) => shippingLabel(option),
bind: { kind: 'searchFilters', key: 'shippingType' },
},
},
},
{
id: 'availability',
header: i18n('column_availability'),
accessorKey: 'availability',
cell: ({ row }: ProductRow) => {
const availability = row.original.availability;
return availability ? availabilityLabel(availability) : null;
},
filterFn: 'multiSelect',
meta: {
filterPlaceholder: i18n('filter_placeholder_availability'),
filterVariant: 'select',
renderSelectOption: (value) => availabilityLabel(value),
style: {
textAlign: 'left',
},
drawer: {
label: i18n('drawer_availability_label'),
widget: 'chips',
options: AVAILABILITY_OPTIONS,
formatChipLabel: (option) => availabilityLabel(option),
bind: { kind: 'searchFilters', key: 'availability' },
},
},
},
{
accessorKey: 'description',
header: i18n('column_description'),
cell: ({ row }: CellContext<Product, unknown>) => row.original.description,
meta: {
truncate: true,
// Descriptions can be very long; cap the content-fit width so the
// column truncates with an ellipsis instead of stretching the table.
autoSizeMax: 180,
style: {
textAlign: 'left',
},
},
},
{
id: 'price',
// Prices are all converted to the user's selected currency, so surface that
// code in the header — it disambiguates symbols shared across currencies
// (e.g. "£" = GBP/GIP/…, "$" = USD/SRD).
header: ({ table }: HeaderContext<Product, unknown>) =>
i18n('column_price_currency', [table.options.meta?.userSettings?.currency ?? 'USD']),
accessorKey: 'price',
// Read userSettings from table meta rather than context so TableColumns()
// stays hook-free — it's called from both React renders and non-render
// code paths (getColumnFilterConfig, DrawerSearchPanel's useMemo), where
// calling useAppContext() would violate the Rules of Hooks. The shared
// formatDisplayPrice helper keeps this in sync with the variant list in
// the expanded detail panel.
cell: ({ row, table }: CellContext<Product, unknown>) =>
formatDisplayPrice(row.original, table.options.meta?.userSettings),
sortingFn: 'priceSortingFn',
filterFn: 'inNumberRangeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_price'),
filterVariant: 'range',
style: {
textAlign: 'left',
},
drawer: {
label: i18n('drawer_price_label'),
widget: 'numberRange',
adornment: 'currency',
bind: {
kind: 'userSettingsRange',
minKey: 'priceMin',
maxKey: 'priceMax',
},
},
},
},
{
id: 'quantity',
header: i18n('column_quantity'),
accessorKey: 'quantity',
meta: {
filterPlaceholder: i18n('filter_placeholder_quantity'),
filterVariant: 'range',
style: {
textAlign: 'left',
},
},
cell: ({ row }: ProductRow) => {
return `${row.original.quantity} ${formatUomForDisplay(row.original.uom)}`;
},
sortingFn: 'quantitySortingFn',
filterFn: 'inNumberRangeHierarchy',
minSize: 50,
},
{
id: 'uom',
header: i18n('column_unit'),
cell: (info) => formatUomForDisplay(info.getValue()),
accessorKey: 'uom',
filterFn: 'multiSelect',
meta: {
filterPlaceholder: i18n('filter_placeholder_unit'),
filterVariant: 'select',
renderSelectOption: (value) => formatUomForDisplay(value),
style: {
textAlign: 'left',
},
},
},
{
id: 'unitPrice',
header: i18n('column_unit_price'),
// Numeric value (per base unit) so the column sorts/filters/auto-hides on
// the amount; the cell renders the "$0.08/g" display string.
accessorFn: (product) => getUnitPrice(product),
cell: ({ row, table }: CellContext<Product, unknown>) => {
const settings = table.options.meta?.userSettings;
const display = formatUnitPrice(row.original, settings);
if (!display) return '';
// When the display was rounded or collapsed to "<$0.01", reveal the
// full-precision value on hover via a native title tooltip.
const exact = formatUnitPriceExact(row.original, settings);
return exact && exact !== display ? <span title={exact}>{display}</span> : display;
},
sortingFn: 'unitPriceSortingFn',
filterFn: 'inNumberRangeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_unit_price'),
filterVariant: 'range',
style: {
textAlign: 'left',
},
},
},
{
id: 'priceTrend',
header: i18n('column_price_trend'),
// Sortable by the trend's signed percent change (rising = +, falling = −),
// stamped onto the row as `priceTrendValue` by useResultsTable — TanStack
// can't sort a column without an accessor, and the price history it derives
// from isn't on the row. `sortUndefined: 'last'` parks trend-less rows at the
// bottom; `meta.skipEmptyHide` keeps the empty-column auto-hide from dropping
// this opt-in column when no row currently has a trend.
accessorFn: (product) => product.priceTrendValue,
// The cell still draws from the preloaded history map in table meta (the
// accessor value above is only the number the column sorts on).
cell: ({ row, table }: CellContext<Product, unknown>) => {
const history = table.options.meta?.priceHistory;
if (history === undefined) return null;
const product = row.original;
// Match the detail panel's variant set: filtered sub-rows when grouped,
// else the product's own variants. A flattened variant row (ungrouped) has
// neither and is resolved by its stamped priceSeriesKey inside the helper.
const variants: Variant[] =
row.subRows.length > 0
? row.subRows.map((sub) => sub.original)
: (product.variants ?? []);
const points = resolveRowTrendPoints(
product,
resolveDisplayedVariants(product, variants),
history,
);
if (!points) return null;
// Hover reveals the same delta/percent badge shown in the variant list.
// PriceHistoryTooltip's light surface keeps the red/green trend colors
// legible — the default dark tooltip bubble washes them out.
return (
<PriceHistoryTooltip
title={<PriceTrend points={points} userSettings={table.options.meta?.userSettings} />}
arrow
placement="top"
>
<span style={{ display: 'inline-flex' }}>
<PriceSparkline points={points} colorByTrend />
</span>
</PriceHistoryTooltip>
);
},
enableColumnFilter: false,
sortUndefined: 'last',
size: 96,
minSize: 96,
maxSize: 140,
meta: {
skipEmptyHide: true,
style: {
textAlign: 'center',
},
},
},
{
id: 'sds',
header: i18n('column_sds'),
cell: ({ row }: ProductRow) => {
const url = row.original.sdsUrl;
if (!url) return null;
return (
<Link
href={url}
aria-label={i18n('product_detail_sds')}
title={i18n('product_detail_sds')}
>
<SDSIcon fontSize="small" />
</Link>
);
},
//enableSorting: true,
enableColumnFilter: false,
minSize: 40,
maxSize: 40,
meta: {
dataKeys: ['sdsUrl'],
style: {
textAlign: 'center',
},
},
},
{
id: 'specs',
header: i18n('column_specs'),
cell: ({ row }: ProductRow) => {
const url = row.original.specSheetUrl;
if (!url) return null;
return (
<Link
href={url}
aria-label={i18n('product_detail_tds')}
title={i18n('product_detail_tds')}
>
<TDSIcon fontSize="small" />
</Link>
);
},
//enableSorting: true,
enableColumnFilter: false,
minSize: 40,
maxSize: 40,
meta: {
dataKeys: ['specSheetUrl'],
style: {
textAlign: 'center',
},
},
},
{
id: 'coa',
header: i18n('column_coa'),
cell: ({ row }: ProductRow) => {
const url = row.original.coaUrl;
if (!url) return null;
return (
<Link
href={url}
aria-label={i18n('product_detail_coa')}
title={i18n('product_detail_coa')}
>
<COAIcon fontSize="small" />
</Link>
);
},
//enableSorting: true,
enableColumnFilter: false,
minSize: 40,
maxSize: 40,
meta: {
dataKeys: ['coaUrl'],
style: {
textAlign: 'center',
},
},
},
{
id: 'cas',
header: i18n('column_cas'),
accessorKey: 'cas',
// Link the CAS number to PubChem: straight to the compound page when the CID is known,
// otherwise to a CAS search that resolves to the matching compound.
cell: ({ row }: ProductRow) => {
const cas = row.original.cas;
if (!cas) return null;
const cid = row.original.pubchemId;
const href = cid ? pubchemCompoundUrl(cid) : pubchemCasSearchUrl(cas);
return (
<Link
href={href}
title={i18n('product_detail_pubchem_view')}
aria-label={`${cas} — ${i18n('product_detail_pubchem_view')}`}
>
{cas}
</Link>
);
},
filterFn: 'includeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_cas'),
filterVariant: 'text',
style: {
textAlign: 'left',
},
},
},
{
id: 'pubchem',
header: i18n('column_pubchem'),
accessorKey: 'pubchemId',
cell: ({ row }: ProductRow) => {
const cid = row.original.pubchemId;
if (!cid) return null;
return (
<Link
href={pubchemCompoundUrl(cid)}
aria-label={i18n('product_detail_pubchem_cid', [String(cid)])}
title={i18n('product_detail_pubchem_view')}
>
{cid}
</Link>
);
},
enableColumnFilter: true,
meta: {
style: {
textAlign: 'left',
},
},
},
{
id: 'formula',
header: i18n('column_formula'),
accessorKey: 'formula',
// The formula is stored with sub/superscripts already converted to
// unicode (see ProductBuilder.setFormula), so render it as plain text.
cell: (info) => info.getValue(),
filterFn: 'includeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_formula'),
filterVariant: 'text',
style: {
textAlign: 'left',
},
},
},
{
id: 'moleweight',
header: i18n('column_moleweight'),
accessorKey: 'moleweight',
cell: (info) => info.getValue(),
filterFn: 'inNumberRangeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_moleweight'),
filterVariant: 'range',
style: {
textAlign: 'left',
},
},
},
{
id: 'purity',
header: i18n('column_purity'),
// Prefer the chemical grade (e.g. "ACS"); fall back to the purity % when no grade is
// set. Using an accessor keeps the displayed value, sorting, and filtering in sync.
accessorFn: (product) => product.grade ?? product.purity ?? 'Ungraded',
cell: (info) => {
const value = info.getValue();
if (!value || value === 'Ungraded')
return <span className={styles.ungraded}>{i18n('purity_ungraded')}</span>;
return value;
},
filterFn: 'includeHierarchy',
// The column mixes grades and percentages, so a string sort would interleave them
// ("ACS Grade" before "95%"). puritySortingFn ranks both on one numeric scale.
sortingFn: 'puritySortingFn',
meta: {
filterPlaceholder: i18n('filter_placeholder_purity'),
filterVariant: 'text',
style: {
textAlign: 'left',
},
},
},
{
id: 'concentration',
header: i18n('column_concentration'),
accessorKey: 'concentration',
cell: (info) => info.getValue(),
filterFn: 'includeHierarchy',
meta: {
filterPlaceholder: i18n('filter_placeholder_concentration'),
filterVariant: 'text',
style: {
textAlign: 'left',
},
},
},
];
}
Defines the column configuration for the product results table. Each column declares its accessor, cell renderer, sort/filter functions, and (optionally)
meta.drawerto opt into a pre-search drawer accordion.The returned order is the render order in both the table header and the drawer's column-backed sections (see
DrawerSearchPanel).