Skip to content

Policies: Dropdowns, Validation, Checkboxes & Masking

A budget sheet where every column’s behavior comes from a Policy: dropdowns with keyword search, value validation and clamping, number formatting, checkboxes, and a masked column with a custom clipboard value. The Status options can be edited below the grid at runtime.

Status options
ApprovedPendingUnder ReviewRejected

📄 View Source Code

A policy is a Policy built from one or more mixins (PolicyMixinType). Register policies by name on the book, then attach them to cells or whole columns with policy: '<name>'.

const book = useSpellbook({
policies: {
department: new Policy({ mixins: [DepartmentPolicy] }),
status: new Policy({ mixins: [StatusPolicy] }),
budget: new Policy({ mixins: [BudgetPolicy, ThousandSeparatorPolicyMixin] }),
thousand_separator: new Policy({ mixins: [ThousandSeparatorPolicyMixin] }),
checkbox: new Policy({ mixins: [CheckboxPolicyMixin] }),
mask: new Policy({ mixins: [MaskPolicy] }),
},
});
// in buildInitialCells({ cells: { ... } })
A: { label: 'Department', policy: 'department' },

Mixins are combined, so budget gets both the clamping below and thousands-separator display.

Returning AutocompleteOption[] from getSelectOptions turns the cell into a dropdown. keywords add search terms, so typing “dev” finds Engineering.

const DEPARTMENT_OPTIONS: AutocompleteOption[] = [
{ value: 'Engineering', label: '🔧 Engineering', keywords: ['Engineering', 'dev', 'development'] },
{ value: 'Marketing', label: '📢 Marketing', keywords: ['Marketing', 'promotion', 'advertising'] },
// ...
];
const DepartmentPolicy: PolicyMixinType = {
getSelectOptions: () => DEPARTMENT_OPTIONS,
select: ({ next }) => {
if (next?.value == null) {
return next;
}
const valid = DEPARTMENT_OPTIONS.some((o) => o.value === next.value);
return valid ? next : { ...next, value: 'Engineering' };
},
};

The dropdown only suggests; typing or pasting can still produce any value. select is what enforces the rule.

select runs before a value is written (typing, paste, autofill). It receives the current and next cell and returns the cell to store. BudgetPolicy clamps amounts to 0–1,000,000:

const BudgetPolicy: PolicyMixinType = {
select: ({ next }) => {
if (next?.value == null) {
return next;
}
const value = Number(next.value);
if (isNaN(value) || value < 0) {
return { ...next, value: 0 };
}
if (value > 1_000_000) {
return { ...next, value: 1_000_000 };
}
return next;
},
};

The Status options live in React state. The policy is created once and reads the options through a ref, so adding or removing a status takes effect immediately without recreating the policy or remounting the grid. Unknown statuses are rejected by keeping the previous value.

const [statuses, setStatuses] = React.useState<string[]>(INITIAL_STATUSES);
const statusesRef = React.useRef(statuses);
React.useEffect(() => {
statusesRef.current = statuses;
}, [statuses]);
const StatusPolicy: PolicyMixinType = {
getSelectOptions: () => statusesRef.current.map((s) => ({ value: s })),
select: ({ current, next }) => {
if (next?.value == null || next.value === '') {
return next;
}
return statusesRef.current.includes(next.value) ? next : { ...next, value: current?.value };
},
};

Removing a status does not change cells that already hold it; it only stops new entries.

CheckboxPolicyMixin renders true / false as a clickable checkbox. The Total row counts them with =COUNTIF(G1:G5,true).

G: { label: 'Done', policy: 'checkbox', alignItems: 'center', justifyContent: 'center' },

Masking with renderString and serializeForClipboard

Section titled “Masking with renderString and serializeForClipboard”

renderString controls how a string is drawn; serializeForClipboard controls what Ctrl/Cmd+C copies (by default, the raw value). Here both show only the last 4 characters:

const mask = (value: unknown) => {
if (value == null || value === '') {
return '';
}
const str = String(value);
return str.length <= 4 ? str : `${'•'.repeat(str.length - 4)}${str.slice(-4)}`;
};
const MaskPolicy: PolicyMixinType = {
renderString: ({ value }) => mask(value),
serializeForClipboard: ({ point, sheet }) => mask(sheet.getSerializedValue({ point })),
};

Read-only cells, borders, and a fixed Total row

Section titled “Read-only cells, borders, and a fixed Total row”
  • prevention: operations.Write makes the Remaining column (=C1-D1) and the Total row read-only.
  • makeBorder sets individual border sides (borderBottom, …) without overwriting the others; it draws the double line above the Total row.
  • sortFixed / filterFixed on the row header cell ('07' = row 7) keep the Total row in place when sorting and visible when filtering.
'A6:G6': { style: { ...makeBorder({ bottom: `4px double ${structBorder}` }) } },
'7': { style: { fontWeight: 'bold' }, prevention: operations.Write },
'07': { sortFixed: true, filterFixed: true },