Feedback
This tutorial shows you how to build an emoji feedback cell using Handsontableβs editorFactory helper, with Handsontable CSS tokens for theme-aware styling and keyboard navigation.
/* file: app.component.ts */import { Component, ChangeDetectorRef, inject } from '@angular/core';import { GridSettings, HotCellEditorAdvancedComponent, KeyboardShortcutConfig, HotTableModule } from '@handsontable/angular-wrapper';import { RowObject } from 'handsontable/common';
/* start:skip-in-preview */const inputData = [ { feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: 'π', votes: 124, status: 'Planned' }, { feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: 'π', votes: 98, status: 'In Progress' }, { feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: 'π€·', votes: 45, status: 'Research' }, { feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: 'π', votes: 12, status: 'Backlog' },];/* end:skip-in-preview */
@Component({ standalone: true, selector: 'example1-feedback-editor', template: ` <div class="feedback-editor"> @for (option of config; track option) { <button [class.active]="option === getValue()" (click)="onClick(option)">{{ option }}</button> } </div> `, styleUrls: ['./example1.css'],})export class FeedbackEditorComponent extends HotCellEditorAdvancedComponent<string> { override config = ['π', 'π', 'π€·']; override value = 'π';
override shortcuts?: KeyboardShortcutConfig[] = [ { keys: [['ArrowRight'], ['Tab']], callback: (_editor, _event) => { let index = this.config.indexOf(this.getValue());
index = index === this.config.length - 1 ? 0 : index + 1; this.setValue(this.config[index]); this.cdr.detectChanges(); return false; }, }, { keys: [['ArrowLeft']], callback: (_editor, _event) => { let index = this.config.indexOf(this.getValue());
index = index === 0 ? this.config.length - 1 : index - 1; this.setValue(this.config[index]); this.cdr.detectChanges(); }, }, ];
private readonly cdr = inject(ChangeDetectorRef);
onClick(option: string): void { this.setValue(option); this.finishEdit.emit(); }}
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-feedback', template: `<div><hot-table [data]="data" [settings]="gridSettings"></hot-table></div>`,})export class AppComponent { readonly data = inputData;
readonly gridSettings: GridSettings = { autoRowSize: true, rowHeaders: true, autoWrapRow: true, height: 'auto', width: '100%', headerClassName: 'htLeft', colHeaders: ['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status'], columns: [ { data: 'feature', type: 'text', width: 200 }, { data: 'category', type: 'text', width: 90 }, { data: 'priority', type: 'text', width: 100 }, { data: 'feedback', width: 100, editor: FeedbackEditorComponent }, { data: 'votes', type: 'numeric', width: 60 }, { data: 'status', type: 'text', width: 120 }, ], };}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-feedback></example1-feedback></div>.feedback-editor { display: flex; gap: var(--ht-gap, 4px); width: 100%; height: 100%; box-sizing: border-box !important; padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px); background-color: var(--ht-cell-editor-background-color, #ffffff); 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); border: none; border-radius: 0;}
.feedback-editor button { display: block; background: var(--ht-background-color, #ffffff); color: var(--ht-foreground-color, #000000); border: 1px solid var(--ht-border-color, #e0e0e0); border-radius: var(--ht-border-radius, 4px); padding: 0; margin: 0; height: 100%; width: 33%; font-size: var(--ht-font-size, 14px); text-align: center; cursor: pointer;}
.feedback-editor button:hover { background: var(--ht-border-color, #e0e0e0);}
.feedback-editor button.active,.feedback-editor button.active:hover { background: var(--ht-accent-color, #1a42e8); color: #ffffff; border-color: var(--ht-accent-color, #1a42e8);}Overview
This guide shows how to create a feedback editor cell using emoji buttons. Use it for status indicators or any scenario where users choose from a small set of visual options.
Difficulty: Beginner Time: ~15 minutes Libraries: None (pure HTML)
What Youβll Build
A cell that:
- Displays emoji feedback buttons (rounded) when editing
- Shows the selected emoji when viewing
- Uses Handsontable CSS tokens for theme-aware styling
- Supports keyboard navigation (arrow keys, Tab)
- Provides click-to-select functionality
- Works without any external libraries
Prerequisites
None! This uses only native HTML and JavaScript features.
Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { registerCellType } from 'handsontable/cellTypes';registerAllModules();What weβre NOT importing:
- No date libraries
- No UI component libraries
- No external emoji libraries
- Handsontable only.
Add CSS Styling
Create a separate CSS file for the editor styles. This uses Handsontable CSS custom properties (tokens) so the editor automatically adapts to custom themes and dark mode.
.feedback-editor {display: flex;gap: var(--ht-gap, 4px);width: 100%;height: 100%;box-sizing: border-box !important;padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px);background-color: var(--ht-cell-editor-background-color, #ffffff);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);border: none;border-radius: 0;}.feedback-editor button {background: var(--ht-background-color, #ffffff);color: var(--ht-foreground-color, #000000);border: 1px solid var(--ht-border-color, #e0e0e0);border-radius: var(--ht-border-radius, 4px);padding: 0;margin: 0;height: 100%;width: 33%;font-size: var(--ht-font-size, 14px);text-align: center;cursor: pointer;}.feedback-editor button:hover {background: var(--ht-border-color, #e0e0e0);}.feedback-editor button.active,.feedback-editor button.active:hover {background: var(--ht-accent-color, #1a42e8);color: #ffffff;border-color: var(--ht-accent-color, #1a42e8);}Handsontable tokens used:
--ht-cell-editor-border-color/--ht-cell-editor-border-width- blue border matching native editors--ht-cell-editor-background-color- editor background--ht-cell-vertical-padding/--ht-cell-horizontal-padding- consistent cell padding--ht-background-color/--ht-foreground-color- base button colors--ht-border-color- button borders and hover state--ht-accent-color- active/selected button highlight--ht-border-radius- button corner rounding--ht-font-size/--ht-gap- consistent sizing
Editor - Initialize (
init)Create the DOM structure with emoji buttons, this function will be called only once.
init(editor) {editor.input = document.createElement('DIV') as HTMLDivElement;editor.input.classList.add('feedback-editor');editor._openedAt = 0;editor.input.addEventListener('click', (event) => {// Ignore synthetic click events that Android fires right after the editor// opens β they land on the button that just appeared at the touch position.if (Date.now() - editor._openedAt < 300) {return;}if (event.target instanceof HTMLButtonElement) {editor.setValue(event.target.innerText);editor.finishEditing();}});editor.render(editor);}Whatβs happening:
- Create a
divcontainer for the buttons - Add the
feedback-editorCSS class (all styling is in the CSS file) - Add click handler to detect button clicks
- When a button is clicked, set the value and finish editing
- Call
renderto create the initial button layout
- Create a
Editor - Render Function
Create buttons dynamically based on the config, using CSS classes instead of inline styles.
render(editor) {editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}">${option}</button>`).join('');}Whatβs happening:
- Generate HTML for each button from
configarray - Add
activeclass to the currently selected button - The
.activeCSS class applies--ht-accent-coloras background - Each button takes 33% width with rounded corners (
--ht-border-radius)
- Generate HTML for each button from
Editor - Keyboard Shortcuts
Add arrow key navigation to cycle through options.
shortcuts: [{keys: [['ArrowRight']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);}},{keys: [['ArrowLeft']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === 0 ? editor.config.length - 1 : index - 1;editor.setValue(editor.config[index]);}}]Whatβs happening:
- ArrowRight: Move to next option (wraps to first if at end)
- ArrowLeft: Move to previous option (wraps to last if at start)
- Finds current index in config array
- Updates value and triggers render automatically
Keyboard navigation benefits:
- Fast selection without mouse
- Accessible for keyboard-only users
- Intuitive left/right navigation
Editor β Custom Tab Key Behavior
By default, pressing Tab in Handsontable saves the cell and moves the selection horizontally, following your layout direction. In this example, Tab cycles through feedback options β the same as the arrow keys β without moving to another cell. The editorβs
shortcutsoption handles this by returningfalsein the callback to prevent the default action (saving and moving to the next cell).shortcuts: [{keys: [['ArrowRight'], ['Tab']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);return false; // Prevent default tabbing behavior},},]How it works:
- Listens for Tab when the editor is active
- Moves to the next option in
config(wraps around at the end) - Updates the editorβs value and button highlight
- Returning
falseblocks Handsontableβs built-in tab handler, so editing stays in place
Editor - Before Open Hook
Initialize the editor with the current cell value when editing starts.
beforeOpen(editor, { originalValue, cellProperties }) {editor.setValue(originalValue);}Whatβs happening:
- Called when editor is about to open
- Receives the current cell value as
originalValue - Sets the editorβs value to match the cell
- This ensures the correct button is highlighted when editing starts
Complete Cell Definition
const cellDefinition = {editor: editorFactory<{ input: HTMLDivElement; value: string; config: string[] }>({config: ['π', 'π', 'π€·'],value: 'π',shortcuts: [{keys: [['ArrowRight'], ['Tab']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);return false;},},{keys: [['ArrowLeft']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === 0 ? editor.config.length - 1 : index - 1;editor.setValue(editor.config[index]);},},],render: (editor) => {editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}">${option}</button>`,).join('');},init: (editor) => {editor.input = document.createElement('DIV') as HTMLDivElement;editor.input.classList.add('feedback-editor');editor._openedAt = 0;editor.input.addEventListener('click', (event) => {if (Date.now() - editor._openedAt < 300) {return;}if (event.target instanceof HTMLButtonElement) {editor.setValue(event.target.innerText);editor.finishEditing();}});editor.render(editor);},afterOpen: (editor) => {editor._openedAt = Date.now();},beforeOpen: (editor, { originalValue, cellProperties }) => {editor.setValue(originalValue);},}),};Whatβs happening:
- config: Array of emoji options (
π,π,π€·) - value: Default/initial value
- shortcuts: Keyboard navigation (ArrowLeft/ArrowRight cycle options, Tab cycles and prevents default)
- render: Creates button HTML with
activeCSS class for the selected option - init: Sets up the container with
feedback-editorclass and click handler - beforeOpen: Initializes editor with the current cell value
Note: No custom renderer needed! Handsontableβs default renderer will display the emoji value in the cell. All visual styling is handled by the CSS file using Handsontable tokens.
- config: Array of emoji options (
Register and Use in Handsontable
Register the cell definition as a reusable cell type, then use it in the column configuration.
registerCellType('feedback', cellDefinition);const container = document.querySelector('#example1')!;const hotOptions: Handsontable.GridSettings = {data: [{ feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: 'π', votes: 124, status: 'Planned' },{ feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: 'π', votes: 98, status: 'In Progress' },{ feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: 'π€·', votes: 45, status: 'Research' },{ feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: 'π', votes: 12, status: 'Backlog' },],colHeaders: ['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status'],autoRowSize: true,rowHeaders: true,autoWrapRow: true,height: 'auto',width: '100%',headerClassName: 'htLeft',columns: [{ data: 'feature', type: 'text', width: 200 },{ data: 'category', type: 'text', width: 90 },{ data: 'priority', type: 'text', width: 100 },{ data: 'feedback', width: 100, type: 'feedback' },{ data: 'votes', type: 'numeric', width: 60 },{ data: 'status', type: 'text', width: 120 },],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
registerCellType('feedback', cellDefinition)- Registers the editor as a reusable cell typetype: 'feedback'- Applies the cell type to the Feedback columnheaderClassName: 'htLeft'- Left-aligns all column headers
How It Works - Complete Flow
- Initial Render: Cell displays the emoji value (π, π, or π€·)
- User Double-Clicks or Enter: Editor opens over cell showing three rounded buttons with the Handsontable blue border
- Button Display: All options visible, current value highlighted using
--ht-accent-color - User Interaction:
- Click a button: Selects value and closes editor
- Press ArrowLeft/ArrowRight: Cycles through options
- Press Tab: Cycles through options (stays in editor)
- Enter key saves value and closes editor
- Visual Feedback: Selected button highlighted with accent color
- Save: Value saved to cell
- Editor Closes: Cell shows selected emoji
Enhancements
More Feedback Options
Add more emoji options by extending the config array and adjusting the button width in CSS:
config: ['π', 'π', 'π€·', 'β€οΈ', 'π₯', 'β'],Dynamic Config from Cell Properties
Make options configurable per column:
beforeOpen: (editor, { cellProperties }) => {if (cellProperties.feedbackOptions) {editor.config = cellProperties.feedbackOptions;}editor.setValue(editor.originalValue || editor.value);},// Usagecolumns: [{data: 'feedback',type: 'feedback',feedbackOptions: ['π', 'π', 'β€οΈ', 'π₯']}]Tooltip on Hover
Add tooltips to buttons:
render: (editor) => {const tooltips = { 'π': 'Positive', 'π': 'Negative', 'π€·': 'Neutral' };editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}" title="${tooltips[option] || ''}">${option}</button>`).join('');}Text Labels Instead of Emojis
Use text buttons for clarity:
config: ['Positive', 'Negative', 'Neutral'],
Accessibility
Keyboard navigation:
- Tab: Cycles through feedback options (stays in editor)
- ArrowLeft / ArrowRight: Cycles through options
- Enter: Saves value and closes editor
- Escape: Cancels editing
- Click: Direct selection
What you learned
You built an emoji feedback cell editor using Handsontableβs editorFactory helper. You used Handsontable CSS custom properties to style the editor in a theme-aware way, and registered the result as a reusable cell type with registerCellType.
Next steps
- Feedback (React) - The same pattern using Reactβs
EditorComponent. - Feedback Editor (Angular) - The Angular version using
HotCellEditorAdvancedComponent. - Star Rating - Another custom editor built with
editorFactoryand SVG stars.