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.
Implementation Guide
Section titled “Implementation Guide”📄 View Source Code
Registering policies
Section titled “Registering policies”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.
Dropdowns with getSelectOptions
Section titled “Dropdowns with getSelectOptions”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.
Validation with select
Section titled “Validation with select”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; },};Runtime-editable options
Section titled “Runtime-editable options”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.
Checkboxes
Section titled “Checkboxes”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.Writemakes the Remaining column (=C1-D1) and the Total row read-only.makeBordersets individual border sides (borderBottom, …) without overwriting the others; it draws the double line above the Total row.sortFixed/filterFixedon 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 },