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 { HotTable, HotColumn } from '@handsontable/react-wrapper';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';import './example1.css';
registerAllModules();
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;
const pikadayRenderer = rendererFactory(({ td, value, cellProperties }) => { td.innerText = value ? moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat) : '';});
const pikadayEditor = 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; editor.input = editor.hot.rootDocument.createElement('INPUT'); editor.datePicker = editor.container; editor.hot.rootDocument.addEventListener('mousedown', (event) => { if (event.target && event.target.classList.contains('pika-day')) { editor.hideDatepicker(editor); } }); }, 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; 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); // @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));
// @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); }
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(); } }, 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; },});
/* 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 */
const ExampleComponent = () => { return ( <HotTable data={data} height="auto" colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']} autoRowSize={true} rowHeaders={true} width="100%" autoWrapRow={true} headerClassName="htLeft" licenseKey="non-commercial-and-evaluation" > <HotColumn data="itemName" type="text" width={130} /> <HotColumn data="category" type="text" width={120} /> <HotColumn data="leadEngineer" type="text" width={150} /> <HotColumn data="restockDate" width={150} allowInvalid={false} hotRenderer={pikadayRenderer} hotEditor={pikadayEditor} 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; }, }} /> <HotColumn data="cost" type="numeric" width={120} className="htRight" locale="en-US" numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }} /> </HotTable> );};
export default ExampleComponent;import { HotTable, HotColumn } from '@handsontable/react-wrapper';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';import './example1.css';
registerAllModules();
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;
interface PikadayEditorInstance { parentDestroyed: boolean; input: HTMLInputElement; datePicker: HTMLElement; pickaday: Pikaday;}
const pikadayRenderer = rendererFactory(({ td, value, cellProperties }) => { td.innerText = value ? moment(new Date(value as string), cellProperties.renderFormat).format(cellProperties.renderFormat) : '';});
const pikadayEditor = editorFactory<PikadayEditorInstance>({ 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; editor.input = editor.hot.rootDocument.createElement('INPUT') as HTMLInputElement; editor.datePicker = editor.container; editor.hot.rootDocument.addEventListener('mousedown', (event: MouseEvent) => { const target = event.target as Element;
if (target && target.classList.contains('pika-day')) { editor.hideDatepicker(editor); } }); }, getDatePickerConfig(editor: any) { const htInput = editor.input; const options: Record<string, any> = {};
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; options.isRTL = false; options.onSelect = function (date: Date) { let dateStr: string | undefined;
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: any) { editor.pickaday?.hide(); }, showDatepicker(editor: any, event: Event) { const dateFormat = editor.getDateFormat(editor); // @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: string;
editor.datePicker.style.display = 'block'; editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));
// @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); }
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(); } }, afterClose(editor: any) { if (editor.pickaday?.destroy) { editor.pickaday.destroy(); } }, afterOpen(editor: any, event: 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: any): string { return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT; },});
/* start:skip-in-preview */interface InventoryItem { id: number; itemName: string; itemNo: string; leadEngineer: string; cost: number; inStock: boolean; category: string; itemQuality: number; origin: string; quantity: number; valueStock: number; repairable: boolean; supplierName: string; restockDate: string; operationalStatus: string;}
const inputData: InventoryItem[] = [ { 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 */
const ExampleComponent = () => { return ( <HotTable data={data} height="auto" colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']} autoRowSize={true} rowHeaders={true} width="100%" autoWrapRow={true} headerClassName="htLeft" licenseKey="non-commercial-and-evaluation" > <HotColumn data="itemName" type="text" width={130} /> <HotColumn data="category" type="text" width={120} /> <HotColumn data="leadEngineer" type="text" width={150} /> <HotColumn data="restockDate" width={150} allowInvalid={false} hotRenderer={pikadayRenderer} hotEditor={pikadayEditor} 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; }, }} /> <HotColumn data="cost" type="numeric" width={120} className="htRight" locale="en-US" numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }} /> </HotTable> );};
export default ExampleComponent;.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
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
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.