Pikaday
This tutorial shows you how to integrate the Pikaday date picker as a custom Handsontable cell editor, with portal positioning and Moment.js formatting. This recipe also serves as a migration guide for users moving away from the built-in date cell type.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from 'pikaday';import 'pikaday/css/pikaday.css';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.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); // Pikaday only formats and parses through Moment.js when it can reach `moment` itself, which // it cannot under a bundler. These two hooks take precedence over its internal path, so the // `format` above is honored both on display and on input. options.toString = (date, format) => moment(date).format(format); options.parse = (dateString, format) => moment(dateString, format).toDate(); 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 (editor.hot.rootWindow.matchMedia('(pointer: coarse)').matches) { 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(); // A printable character reports a single-character `key`; anything else is a function key. const isMeta = event && 'key' in event ? event.key.length > 1 : false;
let dateStr;
editor.datePicker.style.display = 'block'; editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));
// 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.setDate(moment(dateStr, dateFormat).toDate(), 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.setDate(moment(dateStr, dateFormat).toDate(), 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, 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', locale: 'en-US', numericFormat: { style: 'currency', currency: 'USD', minimumFractionDigits: 2, }, }, ], 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 "pikaday";import "pikaday/css/pikaday.css";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.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); } }); }, 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); // Pikaday only formats and parses through Moment.js when it can reach `moment` itself, which // it cannot under a bundler. These two hooks take precedence over its internal path, so the // `format` above is honored both on display and on input. options.toString = (date, format) => moment(date).format(format); options.parse = (dateString, format) => moment(dateString, format).toDate(); 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 (editor.hot.rootWindow.matchMedia("(pointer: coarse)").matches) { 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(); // A printable character reports a single-character `key`; anything else is a function key. const isMeta = event && "key" in event ? (event as KeyboardEvent).key.length > 1 : false; let dateStr;
editor.datePicker.style.display = "block";
editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));
// 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.setDate(moment(dateStr, dateFormat).toDate(), 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.setDate(moment(dateStr, dateFormat).toDate(), 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, 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", locale: "en-US", numericFormat: { style: "currency", currency: "USD", minimumFractionDigits: 2, }, }, ], 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;}
/* Pikaday's own stylesheet ships hardcoded light-mode colors. These rules re-point every visual property at Handsontable's theme variables, so the calendar follows the active theme -- including dark mode.
Every selector is scoped to the element carrying the theme class. That element is the body-level `.ht-portal` the editor is attached to by `position: 'portal'`, so the `--ht-*` variables and the theme's `color-scheme` resolve on the calendar's own ancestor. Scoping this way also keeps each rule one class more specific than the matching rule in `pikaday.css`, which wins on source order otherwise.
Handsontable shipped equivalent rules until 18.0, when the built-in Pikaday date editor was replaced by a native date input. The recipe now owns them. */[class*='ht-theme-'] .pika-single { position: relative; display: block; z-index: 9999; padding: 10px; font-family: var(--ht-font-family), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: var(--ht-font-size); line-height: var(--ht-line-height); font-weight: var(--ht-font-weight); color: var(--ht-foreground-color); background: var(--ht-background-color, #ffffff); border: var(--ht-menu-border-width, 1px) solid var(--ht-menu-border-color, #e5e5e9); border-radius: var(--ht-menu-border-radius); box-shadow: var(--ht-menu-shadow-x, 0) var(--ht-menu-shadow-y, 0) var(--ht-menu-shadow-blur, 8px) color-mix( in srgb, var(--ht-menu-shadow-color, rgba(0, 0, 0)) var(--ht-menu-shadow-opacity, 100%), transparent );}
[class*='ht-theme-'] .pika-single.is-hidden { display: none;}
[class*='ht-theme-'] .pika-single .pika-row { background-color: transparent;}
[class*='ht-theme-'] .pika-single .pika-title { display: flex; align-items: center; justify-content: center;}
[class*='ht-theme-'] .pika-single .pika-label { position: relative; order: 2; padding: 5px; color: var(--ht-accent-color); background-color: transparent;}
[class*='ht-theme-'] .pika-single .pika-label .pika-select { position: absolute; inset: 0; opacity: 0; cursor: pointer;}
/* Replace Pikaday's baked-in PNG arrows with the theme's chevron. `currentColor` on the mask keeps them in step with the accent color in both light and dark mode. */[class*='ht-theme-'] .pika-single .pika-prev,[class*='ht-theme-'] .pika-single .pika-next { width: var(--ht-icon-size, 20px); height: var(--ht-icon-size, 20px); padding: 0; white-space: nowrap; text-indent: -9999px; cursor: pointer; opacity: 1; color: var(--ht-accent-color); border: none; background-image: none; background-color: currentColor; -webkit-mask-size: contain; mask-size: contain; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center;}
[class*='ht-theme-'] .pika-single .pika-prev.is-disabled,[class*='ht-theme-'] .pika-single .pika-next.is-disabled { opacity: 0.2; cursor: default;}
[class*='ht-theme-'] .pika-single .pika-next { order: 3; margin-left: auto; -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.64648 10.9393L9.93938 7.64644L6.64648 4.35354' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A"); mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.64648 10.9393L9.93938 7.64644L6.64648 4.35354' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");}
[class*='ht-theme-'] .pika-single .pika-prev { order: 1; margin-right: auto; -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.49268 11.2929L6.19978 8.00001L9.49268 4.70712' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A"); mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.49268 11.2929L6.19978 8.00001L9.49268 4.70712' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");}
.pika-single .pika-table,[class*='ht-theme-'] .pika-single .pika-table { border-collapse: collapse; table-layout: fixed;}
[class*='ht-theme-'] .pika-single .pika-table thead { height: 50px;}
[class*='ht-theme-'] .pika-single .pika-table th { padding: 1px; vertical-align: middle; color: var(--ht-foreground-color);}
[class*='ht-theme-'] .pika-single .pika-table th abbr { text-decoration: none; font-weight: normal; border: none; cursor: initial;}
[class*='ht-theme-'] .pika-single .pika-table td { padding: 1px; vertical-align: middle;}
.pika-single .pika-table .pika-button,[class*='ht-theme-'] .pika-single .pika-table .pika-button { width: 100%; aspect-ratio: 1 / 1; box-sizing: border-box; margin: 0; padding: 0; background: transparent; color: var(--ht-foreground-color); border: none; border-radius: var(--ht-button-border-radius); box-shadow: none; cursor: pointer; font-size: inherit; text-align: center !important;}
[class*='ht-theme-'] .pika-single .pika-table .pika-button:hover,[class*='ht-theme-'] .pika-single .pika-table td.is-today .pika-button { color: var(--ht-foreground-color); background: color-mix( in srgb, var(--ht-menu-item-hover-color) var(--ht-menu-item-hover-color-opacity, 12%), transparent );}
[class*='ht-theme-'] .pika-single .pika-table td.is-selected .pika-button,[class*='ht-theme-'] .pika-single .pika-table td.is-selected .pika-button:hover { background: var(--ht-accent-color, #1a42e8); color: var(--ht-primary-button-foreground-color, #ffffff); box-shadow: none;}
[class*='ht-theme-'] .pika-single .pika-table td.is-disabled .pika-button { pointer-events: none; opacity: 0.5;}
[class*='ht-theme-'] .pika-single .pika-table td.is-outside-current-month .pika-button { opacity: 0.5;}
[class*='ht-theme-'] .pika-single .pika-table td.pika-week { width: calc(100% / 8); text-align: center; color: var(--ht-foreground-color);}
/* Right-to-left: Pikaday adds `.is-rtl` to the calendar root. */[class*='ht-theme-'] .pika-single.is-rtl .pika-next { transform: rotate(180deg); margin-left: 0; margin-right: auto;}
[class*='ht-theme-'] .pika-single.is-rtl .pika-prev { transform: rotate(180deg); margin-left: auto; margin-right: 0;}Overview
This guide shows how to create a custom date picker cell using Pikaday, a date picker library with no dependencies. Migration note: Handsontable 18.0 removed Pikaday from the built-in date cell type, which now uses a native date input. Use this recipe to keep Pikaday functionality in your application.
Difficulty: Intermediate
Time: ~25 minutes
Libraries: pikaday, moment
What You’ll Build
A cell that:
- Displays formatted dates (e.g., “12/31/2024” or “31/12/2024”)
- Opens a calendar picker when edited, themed to match the grid
- 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 pikaday momentTypeScript users also need the community type definitions, which Pikaday does not ship itself:
npm install --save-dev @types/pikadayWhy these libraries?
pikaday- The Pikaday date picker librarymoment- 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 'pikaday';import 'pikaday/css/pikaday.css';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
pikaday/css/pikaday.cssfor the calendar panel structure- 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
- Add more formats (EU, ISO, custom, etc.) by extending this list
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.toString = (date, format) => moment(date).format(format);options.parse = (dateString, format) => moment(dateString, format).toDate();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 (editor.hot.rootWindow.matchMedia('(pointer: coarse)').matches) {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 (handled via shortcuts)reposition: false: Don’t auto-reposition (positioning is handled manually)toStringandparse: Route display formatting and input parsing through Moment.js
Pikaday formats and parses dates with Moment.js only when it can reach
momentitself, which it cannot under a bundler. Without these two hooks it falls back toDate#toDateString()on display andDate.parse()on input, soformatis ignored and aDD/MM/YYYYcolumn misreads its own values. Pikaday checkstoStringandparsebefore its internal path, so setting them makesformatauthoritative in both directions.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();// A printable character reports a single-character `key`; anything else is a function key.const isMeta = event && 'key' in event? (event as KeyboardEvent).key.length > 1: false;let dateStr;editor.datePicker.style.display = 'block';// Create new Pikaday instanceeditor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));// @ts-ignoreeditor.pickaday._onInputFocus = function () {};// Handle existing valueif (editor.originalValue) {dateStr = editor.originalValue;if (moment(dateStr, dateFormat, true).isValid()) {editor.pickaday.setDate(moment(dateStr, dateFormat).toDate(), 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.setDate(moment(dateStr, dateFormat).toDate(), 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
- Disable input focus handler (focus is handled by the editor lifecycle)
- 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
- The instance check is required: Handsontable can close an editor that never opened (for example, when the edited cell leaves the rendered viewport), so
pickadaymay not exist yet
Editor - Get Value and Set Value
Standard value management methods.
getValue(editor) {return editor.input.value;}setValue(editor, value) {editor.input.value = value;}- Pikaday automatically updates
input.valuewhen a date is selected getValueandsetValueread and write the input value directly- Formatting is handled by Pikaday and the
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 and Calendar Styling (CSS)
Two stylesheets are involved. Style the editor input to match Handsontable’s native editor appearance using CSS custom properties, then re-point the calendar’s own colors at the same theme variables.
.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
Theming the calendar
pikaday/css/pikaday.cssgives the calendar its layout, but every color in it is a hardcoded literal, and its prev/next arrows are baked-in PNG sprites. On a Handsontable theme the panel clashes, and on a dark page it stays light. Override those properties with the theme’s CSS variables:.pika-single {color: var(--ht-foreground-color);background: var(--ht-background-color, #ffffff);border: var(--ht-menu-border-width, 1px) solid var(--ht-menu-border-color, #e5e5e9);border-radius: var(--ht-menu-border-radius);}.pika-single .pika-table .pika-button {width: 100%;aspect-ratio: 1 / 1;box-sizing: border-box;margin: 0;padding: 0;background: transparent;color: var(--ht-foreground-color);border-radius: var(--ht-button-border-radius);text-align: center !important;}.pika-single .pika-table {border-collapse: collapse;table-layout: fixed;}.pika-single .pika-table td.is-selected .pika-button {background: var(--ht-accent-color, #1a42e8);color: var(--ht-primary-button-foreground-color, #ffffff);}table-layout: fixedgives the calendar equal-width day columns, including when week numbers are enabled.box-sizing: border-boxand zero padding keep one- and two-digit day labels centered in their buttons.The full rule set is in the CSS tab of the example above, including the arrow replacement and the right-to-left flip.
Why this works without extra wiring: the editor uses
position: 'portal', so its container is attached to the body-level.ht-portalelement, and Handsontable puts the activeht-theme-*class on that element. Every--ht-*variable and the theme’scolor-schemeresolve on the calendar’s own ancestor, so the panel follows the grid’s theme — light or dark — with no JavaScript.Handsontable shipped equivalent rules until 18.0, when the native date input replaced the built-in Pikaday editor. They now belong to your application.
- 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,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',locale: 'en-US',numericFormat: {style: 'currency',currency: 'USD',minimumFractionDigits: 2,},},],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 the registered 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
Screen readers: Pikaday carries some ARIA attributes of its own, but the last audit found that the built-in date cell type’s picker, which used the same library at the time, was not announced properly by screen readers. Expect the same gap in a custom Pikaday editor until you verify it. The accessibility conformance report lists it under Known open issues (WCAG 4.1.2). Test the picker with VoiceOver (macOS and iOS) and NVDA (Windows) before you rely on it.
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
What you learned
You integrated the Pikaday date picker as a Handsontable cell editor. You used editorFactory with position: 'portal' for correct z-index handling, Moment.js for date formatting and parsing, and cellProperties.datePickerConfig to drive per-column Pikaday configuration.
Next steps
- Moment.js date - A date cell type combining Moment.js and Pikaday in a registered
moment-datecell type. - Flatpickr - An alternative date picker using the Flatpickr library.
- Date picker (Angular) - A date editor built with Angular components and the native HTML5 date input.