Skip to content
JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
import { editorFactory } from 'handsontable/editors';
import { registerCellType } from 'handsontable/cellTypes';
import moment from 'moment';
import Pikaday from '@handsontable/pikaday';
// Register all Handsontable's modules.
registerAllModules();
/* start:skip-in-preview */
const data = [
{
id: 640329,
itemName: 'Lunar Core',
itemNo: 'XJ-12',
leadEngineer: 'Ellen Ripley',
cost: 350000,
inStock: true,
category: 'Lander',
itemQuality: 87,
origin: '🇺🇸 USA',
quantity: 2,
valueStock: 700000,
repairable: false,
supplierName: 'TechNova',
restockDate: '2025-08-01',
operationalStatus: 'Awaiting Parts',
},
{
id: 863104,
itemName: 'Zero Thrusters',
itemNo: 'QL-54',
leadEngineer: 'Sam Bell',
cost: 450000,
inStock: false,
category: 'Propulsion',
itemQuality: 0,
origin: '🇩🇪 Germany',
quantity: 0,
valueStock: 0,
repairable: true,
supplierName: 'PropelMax',
restockDate: '2025-09-15',
operationalStatus: 'In Maintenance',
},
{
id: 395603,
itemName: 'EVA Suits',
itemNo: 'PM-67',
leadEngineer: 'Alex Rogan',
cost: 150000,
inStock: true,
category: 'Equipment',
itemQuality: 79,
origin: '🇮🇹 Italy',
quantity: 50,
valueStock: 7500000,
repairable: true,
supplierName: 'SuitCraft',
restockDate: '2025-10-05',
operationalStatus: 'Ready for Testing',
},
{
id: 679083,
itemName: 'Solar Panels',
itemNo: 'BW-09',
leadEngineer: 'Dave Bowman',
cost: 75000,
inStock: true,
category: 'Energy',
itemQuality: 95,
origin: '🇺🇸 USA',
quantity: 10,
valueStock: 750000,
repairable: false,
supplierName: 'SolarStream',
restockDate: '2025-11-10',
operationalStatus: 'Operational',
},
{
id: 912663,
itemName: 'Comm Array',
itemNo: 'ZR-56',
leadEngineer: 'Louise Banks',
cost: 125000,
inStock: false,
category: 'Communication',
itemQuality: 0,
origin: '🇯🇵 Japan',
quantity: 0,
valueStock: 0,
repairable: true,
supplierName: 'CommTech',
restockDate: '2025-12-20',
operationalStatus: 'Decommissioned',
},
{
id: 315806,
itemName: 'Habitat Dome',
itemNo: 'UJ-23',
leadEngineer: 'Dr. Ryan Stone',
cost: 1000000,
inStock: true,
category: 'Shelter',
itemQuality: 93,
origin: '🇨🇦 Canada',
quantity: 3,
valueStock: 3000000,
repairable: false,
supplierName: 'DomeInnovate',
restockDate: '2026-01-25',
operationalStatus: 'Operational',
},
];
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be rendered
const container = document.querySelector('#example1');
const correctFormat = (value, dateFormat) => {
const dateFromDate = moment(value);
const dateFromMoment = moment(value, dateFormat);
const isAlphanumeric = value.search(/[A-Za-z]/g) > -1;
let date;
if (
(dateFromDate.isValid() && dateFromDate.format('x') === dateFromMoment.format('x')) ||
!dateFromMoment.isValid() ||
isAlphanumeric
) {
date = dateFromDate;
} else {
date = dateFromMoment;
}
return date.format(dateFormat);
};
const cellDateTypeDefinition = {
renderer: getRenderer('autocomplete'),
validator(value, callback) {
let valid = true;
if (value === null || value === undefined) {
value = '';
}
let isValidFormat = moment(value, this.dateFormat, true).isValid();
let isValidDate = moment(new Date(value)).isValid() || isValidFormat;
if (this.allowEmpty && value === '') {
isValidDate = true;
isValidFormat = true;
}
if (!isValidDate) {
valid = false;
}
if (!isValidDate && isValidFormat) {
valid = true;
}
if (isValidDate && !isValidFormat) {
if (this.correctFormat === true) {
const correctedValue = correctFormat(value, this.dateFormat);
this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'dateValidator');
valid = true;
} else {
valid = false;
}
}
callback(valid);
},
editor: editorFactory({
position: 'portal',
shortcuts: [
{
keys: [['ArrowLeft']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('subtract', 1);
_event.preventDefault();
},
},
{
keys: [['ArrowRight']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('add', 1);
_event.preventDefault();
},
},
{
keys: [['ArrowUp']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('subtract', 7);
_event.preventDefault();
},
},
{
keys: [['ArrowDown']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('add', 7);
_event.preventDefault();
},
},
],
init(editor) {
editor.parentDestroyed = false;
// create the input element on init. This is a text input that color picker will be attached to.
editor.input = editor.hot.rootDocument.createElement('input');
editor.datePicker = editor.container;
// Prevent recognizing clicking on datepicker as clicking outside of table.
editor.hot.rootDocument.addEventListener('mousedown', (event) => {
if (event.target && event.target.classList.contains('pika-day')) {
editor.hideDatepicker(editor);
}
});
},
getDatePickerConfig(editor) {
const htInput = editor.input;
const options = {};
if (editor.cellProperties && editor.cellProperties.datePickerConfig) {
Object.assign(options, editor.cellProperties.datePickerConfig);
}
const origOnSelect = options.onSelect;
const origOnClose = options.onClose;
options.field = htInput;
options.trigger = htInput;
options.container = editor.datePicker;
options.bound = false;
options.keyboardInput = false;
options.format = options.format ?? editor.getDateFormat(editor);
options.reposition = options.reposition || false;
// Set the RTL to `false`. Due to the https://github.com/Pikaday/Pikaday/issues/647 bug, the layout direction
// of the date picker is controlled by juggling the "dir" attribute of the root date picker element.
options.isRTL = false;
options.onSelect = function (date) {
let dateStr;
if (!isNaN(date.getTime())) {
dateStr = moment(date).format(editor.getDateFormat(editor));
}
editor.setValue(dateStr);
if (origOnSelect) {
origOnSelect.call(editor.pikaday, date);
}
if (Handsontable.helper.isMobileBrowser()) {
editor.hideDatepicker(editor);
}
};
options.onClose = () => {
if (!editor.parentDestroyed) {
editor.finishEditing(false);
}
if (origOnClose) {
origOnClose();
}
};
return options;
},
hideDatepicker(editor) {
editor.pikaday.hide();
},
showDatepicker(editor, event) {
const dateFormat = editor.getDateFormat(editor);
// TODO: view is not exported in the handsontable library d.ts, so we need to use @ts-ignore
// @ts-ignore
const isMouseDown = editor.hot.view.isMouseDown();
const isMeta = event && 'keyCode' in event ? Handsontable.helper.isFunctionKey(event.keyCode) : false;
let dateStr;
editor.datePicker.style.display = 'block';
editor.pikaday = new Pikaday(editor.getDatePickerConfig(editor));
// TODO: useMoment is not exported in the pikaday library d.ts, so we need to use @ts-ignore
// @ts-ignore
if (typeof editor.pikaday.useMoment === 'function') {
// @ts-ignore
editor.pikaday.useMoment(moment);
}
// TODO: _onInputFocus is not exported in the pikaday library d.ts, so we need to use @ts-ignore
// @ts-ignore
editor.pikaday._onInputFocus = function () {};
if (editor.originalValue) {
dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) {
editor.pikaday.setMoment(moment(dateStr, dateFormat), true);
}
// workaround for date/time cells - pikaday resets the cell value to 12:00 AM by default, this will overwrite the value.
if (editor.getValue() !== editor.originalValue) {
editor.setValue(editor.originalValue);
}
if (!isMeta && !isMouseDown) {
editor.setValue('');
}
} else if (editor.cellProperties.defaultDate) {
dateStr = editor.cellProperties.defaultDate;
if (moment(dateStr, dateFormat, true).isValid()) {
editor.pikaday.setMoment(moment(dateStr, dateFormat), true);
}
if (!isMeta && !isMouseDown) {
editor.setValue('');
}
} else {
// if a default date is not defined, set a soft-default-date: display the current day and month in the
// datepicker, but don't fill the editor input
editor.pikaday.gotoToday();
}
},
afterClose(editor) {
if (editor.pikaday.destroy) {
editor.pikaday.destroy();
}
},
afterOpen(editor, event) {
const cellRect = editor.TD.getBoundingClientRect();
editor.input.style.width = `${cellRect.width}px`;
editor.input.style.height = `${cellRect.height}px`;
editor.showDatepicker(editor, event);
},
getValue(editor) {
return editor.input.value;
},
setValue(editor, value) {
editor.input.value = value;
},
getDateFormat(editor) {
return editor.cellProperties.dateFormat ?? 'DD/MM/YYYY';
},
}),
};
registerCellType('moment-date', cellDateTypeDefinition);
// Define configuration options for the Handsontable
const hotOptions = {
data,
colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'],
autoRowSize: true,
rowHeaders: true,
height: 'auto',
width: '100%',
autoWrapRow: true,
headerClassName: 'htLeft',
columns: [
{ data: 'itemName', type: 'text', width: 130 },
{ data: 'category', type: 'text', width: 120 },
{ data: 'leadEngineer', type: 'text', width: 150 },
{
data: 'restockDate',
type: 'moment-date',
width: 150,
dateFormat: 'YYYY-MM-DD',
correctFormat: true,
datePickerConfig: {
firstDay: 0,
showWeekNumber: true,
disableDayFn(date) {
return date.getDay() === 0 || date.getDay() === 6;
},
},
},
{
data: 'cost',
type: 'numeric',
width: 120,
className: 'htRight',
numericFormat: {
pattern: '$0,0.00',
culture: 'en-US',
},
},
],
licenseKey: 'non-commercial-and-evaluation',
};
// Initialize the Handsontable instance with the specified configuration options
// eslint-disable-next-line no-unused-vars
const hot = new Handsontable(container, hotOptions);
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
import { editorFactory } from 'handsontable/editors';
import { registerCellType } from 'handsontable/cellTypes';
import moment from 'moment';
import Pikaday from '@handsontable/pikaday';
// Register all Handsontable's modules.
registerAllModules();
/* start:skip-in-preview */
const data = [
{
id: 640329,
itemName: 'Lunar Core',
itemNo: 'XJ-12',
leadEngineer: 'Ellen Ripley',
cost: 350000,
inStock: true,
category: 'Lander',
itemQuality: 87,
origin: '🇺🇸 USA',
quantity: 2,
valueStock: 700000,
repairable: false,
supplierName: 'TechNova',
restockDate: '2025-08-01',
operationalStatus: 'Awaiting Parts',
},
{
id: 863104,
itemName: 'Zero Thrusters',
itemNo: 'QL-54',
leadEngineer: 'Sam Bell',
cost: 450000,
inStock: false,
category: 'Propulsion',
itemQuality: 0,
origin: '🇩🇪 Germany',
quantity: 0,
valueStock: 0,
repairable: true,
supplierName: 'PropelMax',
restockDate: '2025-09-15',
operationalStatus: 'In Maintenance',
},
{
id: 395603,
itemName: 'EVA Suits',
itemNo: 'PM-67',
leadEngineer: 'Alex Rogan',
cost: 150000,
inStock: true,
category: 'Equipment',
itemQuality: 79,
origin: '🇮🇹 Italy',
quantity: 50,
valueStock: 7500000,
repairable: true,
supplierName: 'SuitCraft',
restockDate: '2025-10-05',
operationalStatus: 'Ready for Testing',
},
{
id: 679083,
itemName: 'Solar Panels',
itemNo: 'BW-09',
leadEngineer: 'Dave Bowman',
cost: 75000,
inStock: true,
category: 'Energy',
itemQuality: 95,
origin: '🇺🇸 USA',
quantity: 10,
valueStock: 750000,
repairable: false,
supplierName: 'SolarStream',
restockDate: '2025-11-10',
operationalStatus: 'Operational',
},
{
id: 912663,
itemName: 'Comm Array',
itemNo: 'ZR-56',
leadEngineer: 'Louise Banks',
cost: 125000,
inStock: false,
category: 'Communication',
itemQuality: 0,
origin: '🇯🇵 Japan',
quantity: 0,
valueStock: 0,
repairable: true,
supplierName: 'CommTech',
restockDate: '2025-12-20',
operationalStatus: 'Decommissioned',
},
{
id: 315806,
itemName: 'Habitat Dome',
itemNo: 'UJ-23',
leadEngineer: 'Dr. Ryan Stone',
cost: 1000000,
inStock: true,
category: 'Shelter',
itemQuality: 93,
origin: '🇨🇦 Canada',
quantity: 3,
valueStock: 3000000,
repairable: false,
supplierName: 'DomeInnovate',
restockDate: '2026-01-25',
operationalStatus: 'Operational',
},
];
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be rendered
const container = document.querySelector('#example1')!;
const correctFormat = (value: string, dateFormat: string): string => {
const dateFromDate = moment(value);
const dateFromMoment = moment(value, dateFormat);
const isAlphanumeric = value.search(/[A-Za-z]/g) > -1;
let date;
if ((dateFromDate.isValid() && dateFromDate.format('x') === dateFromMoment.format('x')) ||
!dateFromMoment.isValid() ||
isAlphanumeric) {
date = dateFromDate;
} else {
date = dateFromMoment;
}
return date.format(dateFormat);
}
const cellDateTypeDefinition = {
renderer: getRenderer('autocomplete'),
validator: function(value, callback) {
let valid = true;
if (value === null || value === undefined) {
value = '';
}
let isValidFormat = moment(value, this.dateFormat, true).isValid();
let isValidDate = moment(new Date(value)).isValid() || isValidFormat;
if (this.allowEmpty && value === '') {
isValidDate = true;
isValidFormat = true;
}
if (!isValidDate) {
valid = false;
}
if (!isValidDate && isValidFormat) {
valid = true;
}
if (isValidDate && !isValidFormat) {
if (this.correctFormat === true) {
const correctedValue = correctFormat(value, this.dateFormat);
this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'dateValidator');
valid = true;
} else {
valid = false;
}
}
callback(valid);
},
editor: editorFactory({
position: 'portal',
shortcuts: [
{
keys: [['ArrowLeft']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('subtract', 1);
_event.preventDefault();
},
},
{
keys: [['ArrowRight']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('add', 1);
_event.preventDefault();
},
},
{
keys: [['ArrowUp']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('subtract', 7);
_event.preventDefault();
},
},
{
keys: [['ArrowDown']],
callback: (editor, _event) => {
// @ts-ignore
editor.pikaday.adjustDate('add', 7);
_event.preventDefault();
},
},
],
init(editor) {
editor.parentDestroyed = false;
// create the input element on init. This is a text input that color picker will be attached to.
editor.input = editor.hot.rootDocument.createElement('input');
editor.datePicker = editor.container;
// Prevent recognizing clicking on datepicker as clicking outside of table.
editor.hot.rootDocument.addEventListener('mousedown', (event) => {
if (event.target && event.target.classList.contains('pika-day')) {
editor.hideDatepicker(editor);
}
});
},
getDatePickerConfig(editor) {
const htInput = editor.input;
const options = {};
if (editor.cellProperties && editor.cellProperties.datePickerConfig) {
Object.assign(options, editor.cellProperties.datePickerConfig);
}
const origOnSelect = options.onSelect;
const origOnClose = options.onClose;
options.field = htInput;
options.trigger = htInput;
options.container = editor.datePicker;
options.bound = false;
options.keyboardInput = false;
options.format = options.format ?? editor.getDateFormat(editor);
options.reposition = options.reposition || false;
// Set the RTL to `false`. Due to the https://github.com/Pikaday/Pikaday/issues/647 bug, the layout direction
// of the date picker is controlled by juggling the "dir" attribute of the root date picker element.
options.isRTL = false;
options.onSelect = function (date) {
let dateStr;
if (!isNaN(date.getTime())) {
dateStr = moment(date).format(editor.getDateFormat(editor));
}
editor.setValue(dateStr);
if (origOnSelect) {
origOnSelect.call(editor.pikaday, date);
}
if (Handsontable.helper.isMobileBrowser()) {
editor.hideDatepicker(editor);
}
};
options.onClose = () => {
if (!editor.parentDestroyed) {
editor.finishEditing(false);
}
if (origOnClose) {
origOnClose();
}
};
return options;
},
hideDatepicker(editor) {
editor.pikaday.hide();
},
showDatepicker(editor, event) {
const dateFormat = editor.getDateFormat(editor);
// TODO: view is not exported in the handsontable library d.ts, so we need to use @ts-ignore
// @ts-ignore
const isMouseDown = editor.hot.view.isMouseDown();
const isMeta = event && 'keyCode' in event ? Handsontable.helper.isFunctionKey(event.keyCode) : false;
let dateStr;
editor.datePicker.style.display = 'block';
editor.pikaday = new Pikaday(editor.getDatePickerConfig(editor));
// TODO: useMoment is not exported in the pikaday library d.ts, so we need to use @ts-ignore
// @ts-ignore
if (typeof editor.pikaday.useMoment === 'function') {
// @ts-ignore
editor.pikaday.useMoment(moment);
}
// TODO: _onInputFocus is not exported in the pikaday library d.ts, so we need to use @ts-ignore
// @ts-ignore
editor.pikaday._onInputFocus = function () {};
if (editor.originalValue) {
dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) {
editor.pikaday.setMoment(moment(dateStr, dateFormat), true);
}
// workaround for date/time cells - pikaday resets the cell value to 12:00 AM by default, this will overwrite the value.
if (editor.getValue() !== editor.originalValue) {
editor.setValue(editor.originalValue);
}
if (!isMeta && !isMouseDown) {
editor.setValue('');
}
} else if (editor.cellProperties.defaultDate) {
dateStr = editor.cellProperties.defaultDate;
if (moment(dateStr, dateFormat, true).isValid()) {
editor.pikaday.setMoment(moment(dateStr, dateFormat), true);
}
if (!isMeta && !isMouseDown) {
editor.setValue('');
}
} else {
// if a default date is not defined, set a soft-default-date: display the current day and month in the
// datepicker, but don't fill the editor input
editor.pikaday.gotoToday();
}
},
afterClose(editor) {
if (editor.pikaday.destroy) {
editor.pikaday.destroy();
}
},
afterOpen(editor, event) {
const cellRect = editor.TD.getBoundingClientRect();
editor.input.style.width = `${cellRect.width}px`;
editor.input.style.height = `${cellRect.height}px`;
editor.showDatepicker(editor, event);
},
getValue(editor) {
return editor.input.value;
},
setValue(editor, value) {
editor.input.value = value;
},
getDateFormat(editor) {
return editor.cellProperties.dateFormat ?? 'DD/MM/YYYY';
},
}),
};
registerCellType('moment-date', cellDateTypeDefinition);
// Define configuration options for the Handsontable
const hotOptions: Handsontable.GridSettings = {
data,
colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'],
autoRowSize: true,
rowHeaders: true,
height: 'auto',
width: '100%',
autoWrapRow: true,
headerClassName: 'htLeft',
columns: [
{ data: 'itemName', type: 'text', width: 130 },
{ data: 'category', type: 'text', width: 120 },
{ data: 'leadEngineer', type: 'text', width: 150 },
{
data: 'restockDate',
type: 'moment-date',
width: 150,
dateFormat: 'YYYY-MM-DD',
correctFormat: true,
datePickerConfig: {
firstDay: 0,
showWeekNumber: true,
disableDayFn(date: Date) {
return date.getDay() === 0 || date.getDay() === 6;
},
},
},
{
data: 'cost',
type: 'numeric',
width: 120,
className: 'htRight',
numericFormat: {
pattern: '$0,0.00',
culture: 'en-US',
},
},
],
licenseKey: 'non-commercial-and-evaluation',
};
// Initialize the Handsontable instance with the specified configuration options
// eslint-disable-next-line no-unused-vars
const hot = new Handsontable(container, hotOptions);
CSS
.ht_editor_visible > input {
width: 100%;
height: 100%;
box-sizing: border-box !important;
border: none;
border-radius: 0;
outline: none;
margin-top: -1px;
margin-left: -1px;
box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px)
var(--ht-cell-editor-border-color, #1a42e8),
0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0
var(--ht-cell-editor-shadow-color, transparent) !important;
background-color: var(--ht-cell-editor-background-color, #ffffff) !important;
padding: var(--ht-cell-vertical-padding, 4px)
var(--ht-cell-horizontal-padding, 8px) !important;
border: none !important;
font-family: inherit;
font-size: var(--ht-font-size);
line-height: var(--ht-line-height);
}
.ht_editor_visible > input:focus-visible {
border: none !important;
outline: none !important;
}

Overview

This guide shows how to create a custom date cell type using the Moment.js library. Users can format dates using the Moment.js API.

Difficulty: Beginner Time: ~25 minutes Libraries: moment, @handsontable/pikaday

What You’ll Build

A cell that:

  • Displays dates with a dropdown arrow indicator
  • Opens a Pikaday calendar picker when edited
  • Validates and corrects date formats using Moment.js
  • Supports custom dateFormat options per column
  • Disables weekend selection via datePickerConfig

Prerequisites

Terminal window
npm install moment @handsontable/pikaday
  1. Import Dependencies

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    import { getRenderer } from 'handsontable/renderers';
    import { editorFactory } from 'handsontable/editors';
    import { registerCellType } from 'handsontable/cellTypes';
    import moment from 'moment';
    import Pikaday from '@handsontable/pikaday';
    registerAllModules();

    Why this matters:

    • moment handles date parsing, validation, and formatting
    • Pikaday provides the calendar date picker UI
    • editorFactory creates a portal-based editor that overlays the cell
    • registerCellType registers the custom cell type for use in column config
  2. Create the Date Format Helper

    This helper corrects user input to match the expected date format:

    const correctFormat = (value, dateFormat) => {
    const dateFromDate = moment(value);
    const dateFromMoment = moment(value, dateFormat);
    const isAlphanumeric = value.search(/[A-Za-z]/g) > -1;
    let date;
    if ((dateFromDate.isValid() && dateFromDate.format('x') === dateFromMoment.format('x')) ||
    !dateFromMoment.isValid() ||
    isAlphanumeric) {
    date = dateFromDate;
    } else {
    date = dateFromMoment;
    }
    return date.format(dateFormat);
    }

    What’s happening:

    • Tries to parse the value both as a native date and using Moment.js with the given format
    • Picks the best interpretation and reformats it to the target dateFormat
  3. Create the Renderer

    We reuse the built-in autocomplete renderer, which displays a dropdown arrow icon indicating the cell has a picker:

    renderer: getRenderer('autocomplete')
  4. Create the Validator

    The validator checks whether the entered value is a valid date and optionally auto-corrects the format:

    validator: function(value, callback) {
    let valid = true;
    if (value === null || value === undefined) {
    value = '';
    }
    let isValidFormat = moment(value, this.dateFormat, true).isValid();
    let isValidDate = moment(new Date(value)).isValid() || isValidFormat;
    if (this.allowEmpty && value === '') {
    isValidDate = true;
    isValidFormat = true;
    }
    if (!isValidDate) {
    valid = false;
    }
    if (!isValidDate && isValidFormat) {
    valid = true;
    }
    if (isValidDate && !isValidFormat) {
    if (this.correctFormat === true) {
    const correctedValue = correctFormat(value, this.dateFormat);
    this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'dateValidator');
    valid = true;
    } else {
    valid = false;
    }
    }
    callback(valid);
    }

    What’s happening:

    • Validates the date value against the configured dateFormat using Moment.js
    • If correctFormat is enabled, auto-corrects misformatted but valid dates
    • Empty values pass validation when allowEmpty is set
  5. Create the Editor

    The editor uses editorFactory with position: 'portal' to overlay a Pikaday calendar on the cell. Arrow keys navigate days/weeks in the calendar:

    editor: editorFactory({
    position: 'portal',
    shortcuts: [
    {
    keys: [['ArrowLeft']],
    callback: (editor, _event) => {
    editor.pikaday.adjustDate('subtract', 1);
    _event.preventDefault();
    },
    },
    {
    keys: [['ArrowRight']],
    callback: (editor, _event) => {
    editor.pikaday.adjustDate('add', 1);
    _event.preventDefault();
    },
    },
    {
    keys: [['ArrowUp']],
    callback: (editor, _event) => {
    editor.pikaday.adjustDate('subtract', 7);
    _event.preventDefault();
    },
    },
    {
    keys: [['ArrowDown']],
    callback: (editor, _event) => {
    editor.pikaday.adjustDate('add', 7);
    _event.preventDefault();
    },
    },
    ],
    init(editor) {
    editor.parentDestroyed = false;
    editor.input = editor.hot.rootDocument.createElement('input');
    editor.datePicker = editor.container;
    editor.hot.rootDocument.addEventListener('mousedown', (event) => {
    if (event.target && event.target.classList.contains('pika-day')) {
    editor.hideDatepicker(editor);
    }
    });
    },
    afterOpen(editor, event) {
    const cellRect = editor.TD.getBoundingClientRect();
    editor.input.style.width = `${cellRect.width}px`;
    editor.input.style.height = `${cellRect.height}px`;
    editor.showDatepicker(editor, event);
    },
    afterClose(editor) {
    if (editor.pikaday.destroy) {
    editor.pikaday.destroy();
    }
    },
    getValue(editor) {
    return editor.input.value;
    },
    setValue(editor, value) {
    editor.input.value = value;
    },
    getDateFormat(editor) {
    return editor.cellProperties.dateFormat ?? 'DD/MM/YYYY';
    },
    // ... getDatePickerConfig, showDatepicker, hideDatepicker
    // (see the full example above for complete implementation)
    }),

    What’s happening:

    • init creates the input element and binds the Pikaday container
    • afterOpen sizes the input to match the cell dimensions, then opens the date picker
    • afterClose destroys the Pikaday instance to prevent memory leaks
    • Arrow key shortcuts navigate the calendar (left/right = day, up/down = week)
  6. Style the Editor Input

    The editor input needs CSS to match Handsontable’s native editor appearance. Without this, the input shows default browser borders and focus styles:

    .ht_editor_visible > input {
    width: 100%;
    height: 100%;
    box-sizing: border-box !important;
    border: none;
    border-radius: 0;
    outline: none;
    margin-top: -1px;
    margin-left: -1px;
    box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px)
    var(--ht-cell-editor-border-color, #1a42e8),
    0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0
    var(--ht-cell-editor-shadow-color, transparent) !important;
    background-color: var(--ht-cell-editor-background-color, #ffffff) !important;
    padding: var(--ht-cell-vertical-padding, 4px)
    var(--ht-cell-horizontal-padding, 8px) !important;
    border: none !important;
    font-family: inherit;
    font-size: var(--ht-font-size);
    line-height: var(--ht-line-height);
    }
    .ht_editor_visible > input:focus-visible {
    border: none !important;
    outline: none !important;
    }

    Key styling:

    • margin-top: -1px and margin-left: -1px align the editor precisely over the cell border
    • Uses Handsontable’s CSS custom properties (--ht-cell-editor-*) to match the theme
    • inset box-shadow replaces the default border for a consistent editor highlight
    • border: none and outline: none remove default browser focus styles
  7. Register and Use in Handsontable

    registerCellType('moment-date', cellDateTypeDefinition);
    const hotOptions: Handsontable.GridSettings = {
    data,
    colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'],
    autoRowSize: true,
    rowHeaders: true,
    height: 'auto',
    width: '100%',
    autoWrapRow: true,
    headerClassName: 'htLeft',
    columns: [
    { data: 'itemName', type: 'text', width: 130 },
    { data: 'category', type: 'text', width: 120 },
    { data: 'leadEngineer', type: 'text', width: 150 },
    {
    data: 'restockDate',
    type: 'moment-date',
    width: 150,
    dateFormat: 'YYYY-MM-DD',
    correctFormat: true,
    datePickerConfig: {
    firstDay: 0,
    showWeekNumber: true,
    disableDayFn(date) {
    return date.getDay() === 0 || date.getDay() === 6;
    },
    },
    },
    {
    data: 'cost',
    type: 'numeric',
    width: 120,
    className: 'htRight',
    numericFormat: {
    pattern: '$0,0.00',
    culture: 'en-US',
    },
    },
    ],
    licenseKey: 'non-commercial-and-evaluation',
    };
    const hot = new Handsontable(container, hotOptions);

    Key configuration:

    • type: 'moment-date' - uses the custom cell type on the Restock Date column
    • dateFormat: 'YYYY-MM-DD' - the Moment.js format string for parsing and display
    • correctFormat: true - automatically reformats valid dates to the expected format
    • datePickerConfig - passed directly to Pikaday (e.g., disable weekends with disableDayFn)

How It Works - Complete Flow

  1. Initial Render: Cell displays the date value with a dropdown arrow (autocomplete renderer)
  2. User clicks cell: The portal editor opens with an input sized to the cell and a Pikaday calendar below it
  3. Date selection: User picks a date from the calendar or types a value; arrow keys navigate the picker
  4. Validation: Moment.js checks the format and date validity; auto-corrects if correctFormat is enabled
  5. Save: Valid values are saved to the cell; invalid values are rejected