React Data GridPikaday Cell Type - Step-by-Step Guide

Overview

This guide shows how to create a custom date picker cell using Pikaday (opens new window), a lightweight, no-dependencies date picker library. This guide is essential for migration - the built-in date cell type with Pikaday will be removed in the next Handsontable release. Use this recipe to maintain Pikaday functionality in your application.

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

What You'll Build

A cell that:

  • Displays formatted dates (e.g., "12/31/2024" or "31/12/2024")
  • Opens a beautiful calendar picker when edited
  • Supports per-column configuration (date formats, first day of week, disabled dates)
  • Handles keyboard navigation (arrow keys to navigate dates)
  • Auto-closes and saves when a date is selected
  • Works with portal positioning for better z-index handling

Complete Example

Prerequisites

npm install @handsontable/pikaday moment

Why these libraries?

  • @handsontable/pikaday - The Pikaday date picker library (Handsontable's fork)
  • moment - Date formatting and parsing (can be replaced with date-fns, dayjs, etc.)

Step 1: Import Dependencies

import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import moment from 'moment';
import Pikaday from '@handsontable/pikaday';
import { CellProperties } from 'handsontable/settings';
import { editorFactory } from 'handsontable/editors';
import { rendererFactory } from 'handsontable/renderers';

registerAllModules();

What we're importing:

  • Handsontable core and styles
  • editorFactory and rendererFactory for creating custom cell type components
  • Pikaday for date picker functionality
  • Moment for date formatting and parsing

Step 2: Define Date Formats

const DATE_FORMAT_US = 'MM/DD/YYYY';
const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;

Why constants?

  • Reusability across renderer and column configuration
  • Single source of truth
  • Easy to add more formats (EU, ISO, custom, etc.)

Step 3: 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 callbacks
type 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 instance
  • FactoryEditorType: Helper to extract the correct editor type from factory callbacks
  • EditorMethodsType: 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

Step 4: 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:

  • value is the raw date value (e.g., ISO string "2024-12-31" or formatted "12/31/2024")
  • cellProperties.renderFormat is a custom property we'll set per column
  • moment().format() converts to desired format
  • Display the formatted date

Why use cellProperties?

  • Allows different columns to display dates differently
  • One cell definition, multiple configurations

Step 5: 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 container
  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);
      }
    },
  );
}

What's happening:

  1. Initialize parentDestroyed flag to track editor lifecycle
  2. Create an input element using editor.hot.rootDocument.createElement()
  3. Use the editor container as the Pikaday container
  4. Create event listener to handle clicks on datepicker days
  5. The editorFactory helper handles container creation and DOM insertion

Key concepts:

The Event Listener pattern

This is crucial! Without it:

  1. User clicks cell to edit
  2. Pikaday calendar opens
  3. User clicks on a calendar day
  4. Handsontable thinks user clicked "outside" the editor
  5. 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.rootDocument ensures correct document context
  • Ensures compatibility across different environments

Step 6: 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 properties
  if (editor.cellProperties && editor.cellProperties.datePickerConfig) {
    Object.assign(options, editor.cellProperties.datePickerConfig);
  }
  const origOnSelect = options.onSelect;
  const origOnClose = options.onClose;

  // Configure Pikaday
  options.field = htInput;
  options.trigger = htInput;
  options.container = editor.datePicker;
  options.bound = false;
  options.keyboardInput = false;
  options.format = options.format ?? editor.getDateFormat(editor);
  options.reposition = options.reposition || false;
  options.isRTL = false;

  // Handle date selection
  options.onSelect = function (date) {
    let dateStr;

    if (!isNaN(date.getTime())) {
      dateStr = moment(date).format(editor.getDateFormat(editor));
    }
    editor.setValue(dateStr);

    if (origOnSelect) {
      origOnSelect.call(editor.pickaday, date);
    }

    if (Handsontable.helper.isMobileBrowser()) {
      editor.hideDatepicker(editor);
    }
  };

  // Handle date picker close
  options.onClose = () => {
    if (!editor.parentDestroyed) {
      editor.finishEditing(false);
    }
    if (origOnClose) {
      origOnClose();
    }
  };

  return options;
}

What's happening:

  1. Start with empty options object
  2. Merge custom config from cellProperties.datePickerConfig (allows per-column customization)
  3. Store original callbacks to preserve them
  4. Configure Pikaday with editor-specific settings
  5. Set up onSelect to format date and save value
  6. Set up onClose to finish editing

Key configuration options:

  • field: The input element Pikaday attaches to
  • trigger: Element that triggers the picker (same as field)
  • container: Where to render the calendar (editor container)
  • bound: false: Don't position relative to field
  • keyboardInput: false: Disable direct keyboard input (we handle it via shortcuts)
  • reposition: false: Don't auto-reposition (we handle positioning)

Step 7: Editor - Show Datepicker (showDatepicker)

Initialize and display the Pikaday calendar when the editor opens.

showDatepicker(editor, event) {
  const dateFormat = editor.getDateFormat(editor);
  // @ts-ignore
  const isMouseDown = editor.hot.view.isMouseDown();
  const isMeta = event && 'keyCode' in event
    ? Handsontable.helper.isFunctionKey((event as KeyboardEvent).keyCode)
    : false;
  let dateStr;

  editor.datePicker.style.display = 'block';

  // Create new Pikaday instance
  editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));

  // Configure Moment.js integration if available
  // @ts-ignore
  if (typeof editor.pickaday.useMoment === 'function') {
    // @ts-ignore
    editor.pickaday.useMoment(moment);
  }
  // @ts-ignore
  editor.pickaday._onInputFocus = function () {};

  // Handle existing value
  if (editor.originalValue) {
    dateStr = editor.originalValue;

    if (moment(dateStr, dateFormat, true).isValid()) {
      editor.pickaday.setMoment(moment(dateStr, dateFormat), true);
    }

    if (editor.getValue() !== editor.originalValue) {
      editor.setValue(editor.originalValue);
    }

    if (!isMeta && !isMouseDown) {
      editor.setValue('');
    }
  } else if (editor.cellProperties.defaultDate) {
    dateStr = editor.cellProperties.defaultDate;

    if (moment(dateStr, dateFormat, true).isValid()) {
      editor.pickaday.setMoment(moment(dateStr, dateFormat), true);
    }

    if (!isMeta && !isMouseDown) {
      editor.setValue('');
    }
  } else {
    editor.pickaday.gotoToday();
  }
}

What's happening:

  1. Get date format for parsing
  2. Check if mouse is down or function key pressed (for special behavior)
  3. Show the date picker container
  4. Create new Pikaday instance with configuration
  5. Configure Moment.js integration
  6. Disable input focus handler (we handle focus ourselves)
  7. 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 isMeta and isMouseDown checks

  • 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

Step 8: Editor - Hide Datepicker (hideDatepicker)

Close the Pikaday calendar.

hideDatepicker(editor) {
  editor.pickaday.hide();
}

Step 9: 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:

  1. Get the cell's bounding rectangle for exact dimensions
  2. Set the input width and height to match the cell
  3. 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)

Step 10: 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

Step 11: Editor - Get Value and Set Value

Standard value management methods.

getValue(editor) {
  return editor.input.value;
}

setValue(editor, value) {
  editor.input.value = value;
}

Why simple?

  • Pikaday automatically updates input.value when date is selected
  • We just read/write the input value
  • Formatting is handled by Pikaday and our onSelect callback

Step 12: 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;
}

Step 13: 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)

Step 14: 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

Step 15: Editor Input Styling (CSS)

Style the editor input to match Handsontable's native editor appearance using CSS custom properties.

.ht_editor_visible > input {
  width: 100%;
  height: 100%;
  box-sizing: border-box !important;
  border: none;
  border-radius: 0;
  outline: none;
  margin-top: -1px;
  margin-left: -1px;
  box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px)
    var(--ht-cell-editor-border-color, #1a42e8),
    0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 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;
}

What's happening:

  • Uses Handsontable's CSS custom properties (--ht-cell-editor-*) for theme compatibility
  • inset box-shadow replaces borders for the blue editor highlight
  • -1px margins correct portal positioning offset
  • Inherits font properties from the table for consistency
  • :focus-visible overrides prevent browser default focus styles

Step 16: 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
    },
  }),
};

Step 17: Use in Handsontable

const container = document.querySelector('#example1')!;

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

const hot = new Handsontable(container, hotOptions);

Key configuration:

  • ...cellDefinition - Spreads renderer and editor into the column config
  • renderFormat - Format for displaying dates in cells
  • dateFormat - Format for Pikaday date picker
  • datePickerConfig - Additional Pikaday configuration options
  • defaultDate - Default date when cell is empty
  • headerClassName: 'htLeft' - Left-aligns all column headers

How It Works - Complete Flow

  1. Initial Load: Cell displays formatted date (e.g., "08/01/2025")
  2. User Double-Clicks or F2: Editor opens, container positioned in portal
  3. After Open: Input sized to match cell via getBoundingClientRect, showDatepicker called
  4. Show Datepicker: Pikaday instance created, calendar displayed
  5. User Selects Date: onSelect callback fires, formats date, saves value
  6. User Clicks Day: Event listener detects click, hides datepicker, finishes editing
  7. After Close: Pikaday instance destroyed, memory cleaned up
  8. 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 renderFormat property (for cell display)
  • Keep dateFormat and datePickerConfig (they work the same)

Enhancements

1. Different Date Formats per Column

columns: [
  {
    data: 'restockDate',
    ...cellDefinition,
    renderFormat: 'MM/DD/YYYY',  // US format
    dateFormat: 'MM/DD/YYYY',
  },
  {
    data: 'restockDate',
    ...cellDefinition,
    renderFormat: 'DD/MM/YYYY',  // EU format
    dateFormat: 'DD/MM/YYYY',
  }
]

2. Date Range Restrictions

datePickerConfig: {
  minDate: new Date('2024-01-01'),
  maxDate: new Date('2024-12-31'),
  disableDayFn(date) {
    // Disable weekends
    return date.getDay() === 0 || date.getDay() === 6;
  }
}

3. Custom Date Formatting

Replace Moment.js with another library:

import { format, parse } from 'date-fns';

// In renderer
renderer: rendererFactory(({ td, value, cellProperties }) => {
  td.innerText = format(new Date(value), cellProperties.renderFormat);
})

// In getDatePickerConfig
options.format = 'MM/DD/YYYY'; // Pikaday format string

4. Localization

import 'pikaday/css/pikaday.css';
import moment from 'moment';
import 'moment/locale/fr';

moment.locale('fr');

datePickerConfig: {
  i18n: {
    previousMonth: 'Mois précédent',
    nextMonth: 'Mois suivant',
    months: ['Janvier', 'Février', 'Mars', ...],
    weekdays: ['Dimanche', 'Lundi', 'Mardi', ...],
    weekdaysShort: ['Dim', 'Lun', 'Mar', ...]
  }
}

Accessibility

Pikaday has good keyboard support out of the box:

Keyboard navigation:

  • Arrow Keys: Navigate dates (via our shortcuts)
  • Enter: Select current date
  • Escape: Close datepicker
  • Tab: Navigate to next field
  • Page Up/Down: Navigate months
  • Home/End: Navigate to first/last day of month

ARIA attributes: Pikaday automatically adds ARIA attributes for screen readers.

Performance Considerations

Why This Is Fast

  1. Lazy Initialization: Pikaday instance created only when editor opens
  2. Efficient Cleanup: Instance destroyed when editor closes
  3. Portal Positioning: Better z-index handling without performance cost

Congratulations! You've created a production-ready Pikaday date picker cell with full customization options, keyboard navigation, and proper lifecycle management. This recipe ensures you can continue using Pikaday even after the built-in date cell type is removed!