Pikaday
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from '@handsontable/pikaday';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';
// Register all Handsontable's modules.registerAllModules();
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;/* start:skip-in-preview */const inputData = [ { 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', }, { id: 954632, itemName: 'Oxygen Unit', itemNo: 'FK-87', leadEngineer: 'Dr. Grace Augustine', cost: 600000, inStock: true, category: 'Life Support', itemQuality: 85, origin: '🇺🇸 USA', quantity: 15, valueStock: 9000000, repairable: true, supplierName: 'OxyGenius', restockDate: '2026-03-02', operationalStatus: 'Awaiting Parts', }, { id: 734944, itemName: 'Processing Rig', itemNo: 'LK-13', leadEngineer: 'Jake Sully', cost: 350000, inStock: true, category: 'Mining', itemQuality: 81, origin: '🇦🇺 Australia', quantity: 25, valueStock: 8750000, repairable: true, supplierName: 'RigTech', restockDate: '2026-04-15', operationalStatus: 'Ready for Testing', }, { id: 834662, itemName: 'Navigation', itemNo: 'XP-24', leadEngineer: 'Dr. Ellie Arroway', cost: 450000, inStock: true, category: 'Navigation', itemQuality: 89, origin: '🇫🇷 France', quantity: 8, valueStock: 3600000, repairable: false, supplierName: 'NavSolutions', restockDate: '2026-05-30', operationalStatus: 'In Maintenance', }, { id: 714329, itemName: 'Surveyor Arm', itemNo: 'QA-86', leadEngineer: 'Mark Watney', cost: 100000, inStock: true, category: 'Exploration', itemQuality: 78, origin: '🇺🇸 USA', quantity: 40, valueStock: 4000000, repairable: true, supplierName: 'ExploreTech', restockDate: '2026-07-12', operationalStatus: 'Decommissioned', },];
const data = inputData.map((item) => ({ ...item, restockDate: moment(new Date(item.restockDate)).format(DATE_FORMAT_US),}));
/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1');const cellDefinition = { renderer: rendererFactory(({ td, value, cellProperties }) => { td.innerText = moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat); }), editor: editorFactory({ position: 'portal', shortcuts: [ { keys: [['ArrowLeft']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 1); _event.preventDefault(); }, }, { keys: [['ArrowRight']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('add', 1); _event.preventDefault(); }, }, { keys: [['ArrowUp']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 7); _event.preventDefault(); }, }, { keys: [['ArrowDown']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.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.showDatepicker = (editor, event) => { 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); } }); // TODO: fix this https://github.com/handsontable/dev-handsontable/issues/3004 // @ts-ignore // editor.hot.rootPortalElement.appendChild(editor.datePicker); }, // afterInit(editor) { // editor.pickaday = new Pikaday(editor.getDatePickerConfig(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.pickaday, date); }
if (Handsontable.helper.isMobileBrowser()) { editor.hideDatepicker(editor); } }; options.onClose = () => { if (!editor.parentDestroyed) { editor.finishEditing(false); }
if (origOnClose) { origOnClose(); } };
return options; }, hideDatepicker(editor) { editor.pickaday.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.pickaday = 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.pickaday.useMoment === 'function') { // @ts-ignore editor.pickaday.useMoment(moment); }
// TODO: _onInputFocus is not exported in the pikaday library d.ts, so we need to use @ts-ignore // @ts-ignore editor.pickaday._onInputFocus = function () {};
if (editor.originalValue) { dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.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.pickaday.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.pickaday.gotoToday(); } }, afterClose(editor) { if (editor.pickaday.destroy) { editor.pickaday.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 ?? DEFAULT_DATE_FORMAT; }, }),};
// Define configuration options for the Handsontableconst hotOptions = { data, height: 'auto', colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost'], autoRowSize: true, rowHeaders: true, 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', width: 150, allowInvalid: false, ...cellDefinition, renderFormat: DATE_FORMAT_US, dateFormat: DATE_FORMAT_US, correctFormat: true, defaultDate: '01/01/2020', 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-varsconst hot = new Handsontable(container, hotOptions);import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";import moment from "moment";import Pikaday from "@handsontable/pikaday";import { CellProperties } from "handsontable/settings";import { editorFactory } from "handsontable/editors";import { rendererFactory } from "handsontable/renderers";// Register all Handsontable's modules.registerAllModules();
const DATE_FORMAT_US = "MM/DD/YYYY";const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;
/* start:skip-in-preview */
const inputData = [ { 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", }, { id: 954632, itemName: "Oxygen Unit", itemNo: "FK-87", leadEngineer: "Dr. Grace Augustine", cost: 600000, inStock: true, category: "Life Support", itemQuality: 85, origin: "🇺🇸 USA", quantity: 15, valueStock: 9000000, repairable: true, supplierName: "OxyGenius", restockDate: "2026-03-02", operationalStatus: "Awaiting Parts", }, { id: 734944, itemName: "Processing Rig", itemNo: "LK-13", leadEngineer: "Jake Sully", cost: 350000, inStock: true, category: "Mining", itemQuality: 81, origin: "🇦🇺 Australia", quantity: 25, valueStock: 8750000, repairable: true, supplierName: "RigTech", restockDate: "2026-04-15", operationalStatus: "Ready for Testing", }, { id: 834662, itemName: "Navigation", itemNo: "XP-24", leadEngineer: "Dr. Ellie Arroway", cost: 450000, inStock: true, category: "Navigation", itemQuality: 89, origin: "🇫🇷 France", quantity: 8, valueStock: 3600000, repairable: false, supplierName: "NavSolutions", restockDate: "2026-05-30", operationalStatus: "In Maintenance", }, { id: 714329, itemName: "Surveyor Arm", itemNo: "QA-86", leadEngineer: "Mark Watney", cost: 100000, inStock: true, category: "Exploration", itemQuality: 78, origin: "🇺🇸 USA", quantity: 40, valueStock: 4000000, repairable: true, supplierName: "ExploreTech", restockDate: "2026-07-12", operationalStatus: "Decommissioned", },];
const data = inputData.map((item) => ({ ...item, restockDate: moment(new Date(item.restockDate)).format(DATE_FORMAT_US),}));
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector("#example1")!;
type EditorPropertiesType = { input: HTMLInputElement; pickaday: Pikaday; datePicker: HTMLDivElement; parentDestroyed: boolean;};
// Helper type to extract the editor type from factory callbackstype FactoryEditorType<TProps, TMethods> = Parameters< Parameters< typeof editorFactory<TProps, TMethods> >[0]["init"]>[0];
type EditorMethodsType = { showDatepicker: ( editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>, event: Event | undefined, ) => void; hideDatepicker: ( editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>, ) => void; getDatePickerConfig: ( editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>, ) => Pikaday.PikadayOptions; getDateFormat: ( editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>, ) => string;};
const cellDefinition: Pick< CellProperties, "renderer" | "validator" | "editor"> = { renderer: rendererFactory(({ td, value, cellProperties }) => { td.innerText = moment(new Date(value), cellProperties.renderFormat).format( cellProperties.renderFormat, ); }),
editor: editorFactory< EditorPropertiesType, EditorMethodsType >({ position: "portal", shortcuts: [{ keys: [["ArrowLeft"]], callback: (editor, _event) => { //@ts-ignore editor.pickaday.adjustDate("subtract", 1); _event.preventDefault(); }, }, { keys: [["ArrowRight"]], callback: (editor, _event) => { //@ts-ignore editor.pickaday.adjustDate("add", 1); _event.preventDefault(); }, }, { keys: [["ArrowUp"]], callback: (editor, _event) => { //@ts-ignore editor.pickaday.adjustDate("subtract", 7); _event.preventDefault(); }, }, { keys: [["ArrowDown"]], callback: (editor, _event) => { //@ts-ignore editor.pickaday.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", ) as HTMLInputElement;
//editor.showDatepicker = (editor, event) => { 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 as HTMLElement).classList.contains("pika-day") ) { editor.hideDatepicker(editor); } });
// TODO: fix this https://github.com/handsontable/dev-handsontable/issues/3004 // @ts-ignore // editor.hot.rootPortalElement.appendChild(editor.datePicker); }, // afterInit(editor) { // editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor)); // }, getDatePickerConfig(editor) { const htInput = editor.input; const options: Pikaday.PikadayOptions = {};
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.pickaday, date); }
if (Handsontable.helper.isMobileBrowser()) { editor.hideDatepicker(editor); } }; options.onClose = () => { if (!editor.parentDestroyed) { editor.finishEditing(false); } if (origOnClose) { origOnClose(); } };
return options; }, hideDatepicker(editor) { editor.pickaday.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 as KeyboardEvent).keyCode) : false; let dateStr;
editor.datePicker.style.display = "block";
editor.pickaday = 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.pickaday.useMoment === "function") { // @ts-ignore editor.pickaday.useMoment(moment); } // TODO: _onInputFocus is not exported in the pikaday library d.ts, so we need to use @ts-ignore // @ts-ignore editor.pickaday._onInputFocus = function () {};
if (editor.originalValue) { dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.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.pickaday.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.pickaday.gotoToday(); } }, afterClose(editor) { if (editor.pickaday.destroy) { editor.pickaday.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: FactoryEditorType<EditorPropertiesType, EditorMethodsType>, ) { return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT; }, }),};
// Define configuration options for the Handsontableconst hotOptions: Handsontable.GridSettings = { data, height: "auto", colHeaders: ["Item Name", "Category", "Lead Engineer", "Restock Date", "Cost"], autoRowSize: true, rowHeaders: true, 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", width: 150, allowInvalid: false, ...cellDefinition, renderFormat: DATE_FORMAT_US, dateFormat: DATE_FORMAT_US, correctFormat: true, defaultDate: "01/01/2020", 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-varsconst hot = new Handsontable(container, hotOptions);.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 picker cell using Pikaday, a lightweight, no-dependencies date picker library. This guide is essential for migration - the built-in date cell type with Pikaday will be removed in the next Handsontable release. Use this recipe to maintain Pikaday functionality in your application.
Difficulty: Intermediate
Time: ~25 minutes
Libraries: @handsontable/pikaday, moment
What You’ll Build
A cell that:
- Displays formatted dates (e.g., “12/31/2024” or “31/12/2024”)
- Opens a beautiful calendar picker when edited
- Supports per-column configuration (date formats, first day of week, disabled dates)
- Handles keyboard navigation (arrow keys to navigate dates)
- Auto-closes and saves when a date is selected
- Works with portal positioning for better z-index handling
Prerequisites
npm install @handsontable/pikaday momentWhy these libraries?
@handsontable/pikaday- The Pikaday date picker library (Handsontable’s fork)moment- Date formatting and parsing (can be replaced with date-fns, dayjs, etc.)
Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from '@handsontable/pikaday';import { CellProperties } from 'handsontable/settings';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';registerAllModules();What we’re importing:
- Handsontable core and styles
editorFactoryandrendererFactoryfor creating custom cell type components- Pikaday for date picker functionality
- Moment for date formatting and parsing
Define Date Formats
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;Why constants?
- Reusability across renderer and column configuration
- Single source of truth
- Easy to add more formats (EU, ISO, custom, etc.)
Define TypeScript Types
Define types for editor properties and methods to ensure type safety.
type EditorPropertiesType = {input: HTMLInputElement;pickaday: Pikaday;datePicker: HTMLDivElement;parentDestroyed: boolean;};// Helper type to extract the editor type from factory callbackstype FactoryEditorType<TProps, TMethods> = Parameters<Parameters<typeof editorFactory<TProps, TMethods>>[0]["init"]>[0];type EditorMethodsType = {showDatepicker: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,event: Event | undefined,) => void;hideDatepicker: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => void;getDatePickerConfig: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => Pikaday.PikadayOptions;getDateFormat: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => string;};What’s happening:
EditorPropertiesType: Defines custom properties added to the editor instanceFactoryEditorType: Helper to extract the correct editor type from factory callbacksEditorMethodsType: Defines custom methods that will be available on the editor
Why this matters:
- Provides full TypeScript type safety
- Enables autocomplete in your IDE
- Catches type errors at compile time
Create the Renderer
The renderer controls how the cell looks when not being edited.
renderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat,);})What’s happening:
valueis the raw date value (e.g., ISO string “2024-12-31” or formatted “12/31/2024”)cellProperties.renderFormatis a custom property we’ll set per columnmoment().format()converts to desired format- Display the formatted date
Why use
cellProperties?- Allows different columns to display dates differently
- One cell definition, multiple configurations
Editor - Initialize (
init)Create the input element and set up event handling to prevent clicks on the datepicker from closing the editor.
init(editor) {editor.parentDestroyed = false;// Create the input element on init. This is a text input that date picker will be attached to.editor.input = editor.hot.rootDocument.createElement('input',) as HTMLInputElement;// Use the container as the date picker containereditor.datePicker = editor.container;/*** Prevent recognizing clicking on datepicker as clicking outside of table.*/editor.hot.rootDocument.addEventListener('mousedown', (event) => {if (event.target &&(event.target as HTMLElement).classList.contains('pika-day')) {editor.hideDatepicker(editor);}},);}What’s happening:
- Initialize
parentDestroyedflag to track editor lifecycle - Create an
inputelement usingeditor.hot.rootDocument.createElement() - Use the editor container as the Pikaday container
- Create event listener to handle clicks on datepicker days
- The
editorFactoryhelper handles container creation and DOM insertion
Key concepts:
The
Event ListenerpatternThis is crucial! Without it:
- User clicks cell to edit
- Pikaday calendar opens
- User clicks on a calendar day
- Handsontable thinks user clicked “outside” the editor
- Editor closes immediately!
Solution:
editor.hot.rootDocument.addEventListener('mousedown', (event) => {if ((event.target as HTMLElement).classList.contains('pika-day')) {editor.hideDatepicker(editor);}});Why
editor.hot.rootDocument.createElement()?- Handsontable might be in an iframe or shadow DOM
editor.hot.rootDocumentensures correct document context- Ensures compatibility across different environments
- Initialize
Editor - Get Date Picker Config (
getDatePickerConfig)Build the Pikaday configuration object with proper callbacks.
getDatePickerConfig(editor) {const htInput = editor.input;const options: Pikaday.PikadayOptions = {};// Merge custom config from cell propertiesif (editor.cellProperties && editor.cellProperties.datePickerConfig) {Object.assign(options, editor.cellProperties.datePickerConfig);}const origOnSelect = options.onSelect;const origOnClose = options.onClose;// Configure Pikadayoptions.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;options.isRTL = false;// Handle date selectionoptions.onSelect = function (date) {let dateStr;if (!isNaN(date.getTime())) {dateStr = moment(date).format(editor.getDateFormat(editor));}editor.setValue(dateStr);if (origOnSelect) {origOnSelect.call(editor.pickaday, date);}if (Handsontable.helper.isMobileBrowser()) {editor.hideDatepicker(editor);}};// Handle date picker closeoptions.onClose = () => {if (!editor.parentDestroyed) {editor.finishEditing(false);}if (origOnClose) {origOnClose();}};return options;}What’s happening:
- Start with empty options object
- Merge custom config from
cellProperties.datePickerConfig(allows per-column customization) - Store original callbacks to preserve them
- Configure Pikaday with editor-specific settings
- Set up
onSelectto format date and save value - Set up
onCloseto finish editing
Key configuration options:
field: The input element Pikaday attaches totrigger: Element that triggers the picker (same as field)container: Where to render the calendar (editor container)bound: false: Don’t position relative to fieldkeyboardInput: false: Disable direct keyboard input (we handle it via shortcuts)reposition: false: Don’t auto-reposition (we handle positioning)
Editor - Show Datepicker (
showDatepicker)Initialize and display the Pikaday calendar when the editor opens.
showDatepicker(editor, event) {const dateFormat = editor.getDateFormat(editor);// @ts-ignoreconst isMouseDown = editor.hot.view.isMouseDown();const isMeta = event && 'keyCode' in event? Handsontable.helper.isFunctionKey((event as KeyboardEvent).keyCode): false;let dateStr;editor.datePicker.style.display = 'block';// Create new Pikaday instanceeditor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));// Configure Moment.js integration if available// @ts-ignoreif (typeof editor.pickaday.useMoment === 'function') {// @ts-ignoreeditor.pickaday.useMoment(moment);}// @ts-ignoreeditor.pickaday._onInputFocus = function () {};// Handle existing valueif (editor.originalValue) {dateStr = editor.originalValue;if (moment(dateStr, dateFormat, true).isValid()) {editor.pickaday.setMoment(moment(dateStr, dateFormat), true);}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.pickaday.setMoment(moment(dateStr, dateFormat), true);}if (!isMeta && !isMouseDown) {editor.setValue('');}} else {editor.pickaday.gotoToday();}}What’s happening:
- Get date format for parsing
- Check if mouse is down or function key pressed (for special behavior)
- Show the date picker container
- Create new Pikaday instance with configuration
- Configure Moment.js integration
- Disable input focus handler (we handle focus ourselves)
- Set initial date based on cell value, default date, or today
Key concepts:
Why create Pikaday instance in
showDatepicker?- Pikaday instance is created fresh each time editor opens
- Allows per-edit configuration changes
- Ensures clean state for each edit session
The
isMetaandisMouseDownchecks- If function key (F2, etc.) or mouse is down, don’t clear the input
- Preserves value when opening via keyboard or programmatically
- Provides better UX for keyboard users
Editor - Hide Datepicker (
hideDatepicker)Close the Pikaday calendar.
hideDatepicker(editor) {editor.pickaday.hide();}Editor - After Open Hook (
afterOpen)Match the editor input dimensions to the cell and show the datepicker.
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);}What’s happening:
- Get the cell’s bounding rectangle for exact dimensions
- Set the input width and height to match the cell
- Show the datepicker
Why
getBoundingClientRect?- Provides pixel-perfect dimensions matching the cell
- Works correctly regardless of cell padding, borders, or theme
- The CSS file handles the visual styling (borders, padding, focus states)
Editor - After Close Hook (
afterClose)Clean up the Pikaday instance when the editor closes.
afterClose(editor) {if (editor.pickaday.destroy) {editor.pickaday.destroy();}}Why this matters:
- Pikaday creates DOM elements and event listeners
- Without cleanup, these accumulate over time
- Essential for long-running applications
Editor - Get Value and Set Value
Standard value management methods.
getValue(editor) {return editor.input.value;}setValue(editor, value) {editor.input.value = value;}Why simple?
- Pikaday automatically updates
input.valuewhen date is selected - We just read/write the input value
- Formatting is handled by Pikaday and our
onSelectcallback
- Pikaday automatically updates
Editor - Get Date Format (
getDateFormat)Helper method to get the date format for the current cell.
getDateFormat(editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) {return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT;}Editor - Keyboard Shortcuts
Add keyboard navigation for date selection.
shortcuts: [{keys: [['ArrowLeft']],callback: (editor, _event) => {editor.pickaday.adjustDate('subtract', 1);_event.preventDefault();},},{keys: [['ArrowRight']],callback: (editor, _event) => {editor.pickaday.adjustDate('add', 1);_event.preventDefault();},},{keys: [['ArrowUp']],callback: (editor, _event) => {editor.pickaday.adjustDate('subtract', 7);_event.preventDefault();},},{keys: [['ArrowDown']],callback: (editor, _event) => {editor.pickaday.adjustDate('add', 7);_event.preventDefault();},}]What’s happening:
- ArrowLeft: Move back one day
- ArrowRight: Move forward one day
- ArrowUp: Move back one week (7 days)
- ArrowDown: Move forward one week (7 days)
Editor - Portal Positioning
Use portal positioning for better z-index handling.
position: 'portal',Why portal instead of container?
- Datepicker can extend beyond cell boundaries
- Portal ensures it’s always on top
- Better for complex layouts
Editor Input Styling (CSS)
Style the editor input to match Handsontable’s native editor appearance using CSS custom properties.
.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) 0var(--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;}What’s happening:
- Uses Handsontable’s CSS custom properties (
--ht-cell-editor-*) for theme compatibility inset box-shadowreplaces borders for the blue editor highlight-1pxmargins correct portal positioning offset- Inherits font properties from the table for consistency
:focus-visibleoverrides prevent browser default focus styles
- Uses Handsontable’s CSS custom properties (
Complete Cell Definition
Put it all together:
const cellDefinition: Pick<CellProperties,'renderer' | 'validator' | 'editor'> = {renderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat,);}),editor: editorFactory<EditorPropertiesType,EditorMethodsType>({position: 'portal',shortcuts: [// ... keyboard shortcuts from Step 13],init(editor) {// ... from Step 5},getDatePickerConfig(editor) {// ... from Step 6},hideDatepicker(editor) {// ... from Step 8},showDatepicker(editor, event) {// ... from Step 7},afterClose(editor) {// ... from Step 10},afterOpen(editor, event) {// ... from Step 9},getValue(editor) {// ... from Step 11},setValue(editor, value) {// ... from Step 11},getDateFormat(editor) {// ... from Step 12},}),};Use in Handsontable
const container = document.querySelector('#example1')!;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',width: 150,allowInvalid: false,...cellDefinition,renderFormat: DATE_FORMAT_US,dateFormat: DATE_FORMAT_US,correctFormat: true,defaultDate: '01/01/2020',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',};const hot = new Handsontable(container, hotOptions);Key configuration:
...cellDefinition- Spreads renderer and editor into the column configrenderFormat- Format for displaying dates in cellsdateFormat- Format for Pikaday date pickerdatePickerConfig- Additional Pikaday configuration optionsdefaultDate- Default date when cell is emptyheaderClassName: 'htLeft'- Left-aligns all column headers
How It Works - Complete Flow
- Initial Load: Cell displays formatted date (e.g., “08/01/2025”)
- User Double-Clicks or F2: Editor opens, container positioned in portal
- After Open: Input sized to match cell via
getBoundingClientRect,showDatepickercalled - Show Datepicker: Pikaday instance created, calendar displayed
- User Selects Date:
onSelectcallback fires, formats date, saves value - User Clicks Day: Event listener detects click, hides datepicker, finishes editing
- After Close: Pikaday instance destroyed, memory cleaned up
- Re-render: Cell displays updated formatted date
Migration from Built-in Date Cell Type
If you’re currently using the built-in date cell type with Pikaday, here’s how to migrate:
Before (Built-in):
columns: [{ data: 'restockDate', type: 'date', dateFormat: 'MM/DD/YYYY', datePickerConfig: { firstDay: 0, }}]After (Custom Editor):
columns: [{ data: 'restockDate', ...cellDefinition, // Add the custom cell definition dateFormat: 'MM/DD/YYYY', renderFormat: 'MM/DD/YYYY', // Add render format datePickerConfig: { firstDay: 0, }}]Key differences:
- Replace
type: "date"with...cellDefinition - Add
renderFormatproperty (for cell display) - Keep
dateFormatanddatePickerConfig(they work the same)
Enhancements
Different Date Formats per Column
columns: [{data: 'restockDate',...cellDefinition,renderFormat: 'MM/DD/YYYY', // US formatdateFormat: 'MM/DD/YYYY',},{data: 'restockDate',...cellDefinition,renderFormat: 'DD/MM/YYYY', // EU formatdateFormat: 'DD/MM/YYYY',}]Date Range Restrictions
datePickerConfig: {minDate: new Date('2024-01-01'),maxDate: new Date('2024-12-31'),disableDayFn(date) {// Disable weekendsreturn date.getDay() === 0 || date.getDay() === 6;}}Custom Date Formatting
Replace Moment.js with another library:
import { format, parse } from 'date-fns';// In rendererrenderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = format(new Date(value), cellProperties.renderFormat);})// In getDatePickerConfigoptions.format = 'MM/DD/YYYY'; // Pikaday format stringLocalization
import 'pikaday/css/pikaday.css';import moment from 'moment';import 'moment/locale/fr';moment.locale('fr');datePickerConfig: {i18n: {previousMonth: 'Mois précédent',nextMonth: 'Mois suivant',months: ['Janvier', 'Février', 'Mars', ...],weekdays: ['Dimanche', 'Lundi', 'Mardi', ...],weekdaysShort: ['Dim', 'Lun', 'Mar', ...]}}
Accessibility
Pikaday has good keyboard support out of the box:
Keyboard navigation:
- Arrow Keys: Navigate dates (via our shortcuts)
- Enter: Select current date
- Escape: Close datepicker
- Tab: Navigate to next field
- Page Up/Down: Navigate months
- Home/End: Navigate to first/last day of month
ARIA attributes: Pikaday automatically adds ARIA attributes for screen readers.
Performance Considerations
Why This Is Fast
- Lazy Initialization: Pikaday instance created only when editor opens
- Efficient Cleanup: Instance destroyed when editor closes
- Portal Positioning: Better z-index handling without performance cost
Congratulations! You’ve created a production-ready Pikaday date picker cell with full customization options, keyboard navigation, and proper lifecycle management. This recipe ensures you can continue using Pikaday even after the built-in date cell type is removed!