Editor's Note
handsontable-editor-dev
Use when creating or modifying a Handsontable cell editor - covers the editor lifecycle state machine (VIRGIN/EDITING/WAITING/FINISHED), DOM management, focus handling, positioning with getEditedCellRect, and validation integration
Install
npx skills add https://github.com/handsontable/handsontable --skill handsontable-editor-devHandsontable Editor Development
Editor state machine
Four states: VIRGIN (never opened), EDITING (visible, accepting input), WAITING (editing finished, awaiting async validation), FINISHED (validation complete, editor closed). Move through the states in order: never skip one (VIRGIN straight to FINISHED, or past WAITING on async validation), and go backwards only through a full reset.
File structure
src/editors/{editorName}/
{editorName}.ts # Main class extending BaseEditor
index.ts # Re-exports
Registry: src/editors/registry.ts. Factory: src/editors/factory.ts.
Lifecycle methods (required overrides)
| Method | Purpose |
|---|---|
init() | Create DOM elements, set up event listeners. |
prepare() | Called before editing starts. Receives row, col, prop, TD, cellProperties. |
getValue() | Return the current editor value. |
setValue(newValue) | Set the editor value (called before open). |
open() | Show the editor, position it, capture focus. |
close() | Hide the editor, release focus. |
focus() | Set focus to the editor's input element. |
beginEditing() | Start the editing process (calls prepare, setValue, open). |
finishEditing() | End editing, trigger validation, close if valid. |
Key patterns
- Extend
BaseEditorfromsrc/editors/baseEditor/baseEditor.ts. - Position with
getEditedCellRect()(viewport-, scroll-, and overlay-aware; manual math breaks with frozen rows/columns and scrolled viewports). - Support full edit mode (Enter opens, all keys go to the editor) and fast edit mode (typing a character opens the editor with it).
finishEditing()supports async validation (WAITING until the validator resolves).- Create DOM with
this.hot.rootDocument(iframe support). - Use
EventManagerfor listeners so they are cleaned up automatically. - Call
superin lifecycle overrides (super.init(),super.close(), etc.). - Release DOM elements and listeners in
close(); restore focus so keyboard navigation works after closing. - Support LTR and RTL: logical CSS properties or
this.hot.isRtl().
Reference implementations: src/editors/textEditor/textEditor.ts (textarea), src/editors/selectEditor/selectEditor.ts (dropdown), src/editors/baseEditor/baseEditor.ts (lifecycle and state transitions).
IME gotcha
For CJK, compositionstart/compositionend timing affects when the value can be read. Read or commit the value only outside a composition (between the events it is intermediate). BaseEditor handles this; custom editors that override key event handling must respect composition state.
Dropdown lists are positioned fixed (#8688)
The list of the handsontable, autocomplete, dropdown, and multiselect editors stays a DOM child of the grid root (focus, ARIA, outside-click detection untouched) but is position: fixed, so the root's overflow: clip (any sized height) and scrolling ancestors cannot cut it. Traps:
- A
fixedbox is not always laid out in the viewport; the host page decides. An ancestor withtransform,translate,rotate,scale,perspective,filter,backdrop-filter, awill-changenaming one of those orcontain, orcontain: paint|layout|strict|contentbecomes the containing block, andtop/leftresolve against its padding box.container-typealone (size,inline-size,scroll-state) does NOT anchor a fixed descendant (measured in Chromium 148/151, Firefox 153, WebKit 26.5; onlycontainer-typeplus an explicitcontain: layoutdoes, and thencontainis the cause). Checking it put the list 208px off its cell in a container-query wrapper. Before adding a property to that list, measure it in all three engines beside a control that DOES anchor (transform: translateX(0)). A grid in a centred modal (transform: translate(-50%, -50%)) drifted the list 313px down and 384px right with rawgetBoundingClientRect()values. Handsontable's own DOM sets none of these, so only the host page matters. Route every coordinate throughgetFixedContainingBlockRect()(helpers/dom/element.ts), which returns that box in viewport coordinates (falling back to the viewport); subtract its origin unconditionally. The vertical flip decision and the clamp use that box intersected with the viewport (getVisibleVerticalRange()): the box scrolls with the page, so its bottom can lie below the viewport's. - The list paints inside its ancestor stacking context.
.ht-root-wrapper.ht-shadow-domcarriesisolation: isolate(styles/base/_base.scss), so the list'sz-index: 200stays confined there: underht-shadow-dom, or a host that puts the grid in its ownz-indexlayer, the list paints under the host's chrome. - Vertical axis measures against the window; horizontal still measures against the grid's workspace. Only
flipDropdownVerticallyIfNeeded()andMultiSelectEditor#getAvailableSpace()measurecell.getBoundingClientRect()againstgetVisibleVerticalRange()(the containing block cut todocumentElement.clientHeight, throughgetVerticalRoom()), neverrootWindow.innerHeight.flipDropdownHorizontallyIfNeeded()keepsgetWorkspaceWidth()(8 legacy specs pin the sideways placement). Keep the horizontal axis workspace-based. - Exception: a grid whose columns the WINDOW scrolls measures the viewport, and under RTL from the viewport's RIGHT edge. When
view.isHorizontallyScrollableByWindow(),HandsontableEditor#flipDropdownHorizontallyIfNeeded()andMultiSelectEditor#getInlineSpace()read the viewport from one helper,getWindowScrollInlineMetrics()(handsontable/src/editors/utils/windowScrollInlineMetrics.ts), add its offset togetEditedCellRect()'sstart + width, and measure room againstdocumentElement.clientWidth. That sum counts from the root's inline-start edge (its right edge in RTL), so the RTL offset isclientWidth - rootElement.getBoundingClientRect().right; the LTR offset isrootElement.getBoundingClientRect().left(getTableOffset().left - rootWindow.scrollXignored a translating ancestor, and applied under RTL it was off by the page's horizontal scroll, so every list flipped once the page scrolled toward its inline end; shipped from #11593 and #13548 until DEV-3138). Measure that room against the viewport, notgetFixedContainingBlockRect(): atransform/filter/will-changeancestor makes the block scroll with the page and the list is not clipped to it. The block is only the origin therightvalue is written from. Pinned bytests/e2e/handsontable-editor-list-position.spec.ts(handsontable,dropdown,autocompletewithtrimDropdown: false,multiselect: grid inset 600 px from the inline-start edge, transformed container, page scrolled with the list open) andeditors/utils/__tests__/windowScrollInlineMetrics.unit.js. The multiselect list flipped above is anchored bybottom(DropdownController#toggleVerticalFlip), not by a computed height:getHeight()underestimates the rendered list. getFixedContainingBlockRect()'s viewport fallback is measured with a probe, not derived (DEV-3204). A gutter at the LEFT of the viewport (root scrollbar in Firefox withbidi.direction: 2orlayout.scrollbar.side: 3; orscrollbar-gutter: stable both-edges) means the fixed box spans x 15 to the viewport's right edge, not 0 toclientWidth.measureViewportFixedBox()reads an emptyposition: fixed; left: 0; right: 0element wheninnerWidthexceedsclientWidth. Never usedocumentElement.clientLeft: Chromium adds the<html>border-left to it, Firefox does not, and a reservedscrollbar-gutterleaves it 0. Chromium keeps the root scrollbar on the right on RTL pages; Firefox does unless its UI is RTL (layout.scrollbar.side: 0followsbidi.direction, not the page). The window-scroll horizontal flip still measuresclientWidthfrom x=0. Headless Playwright has 0 px overlay scrollbars and headless Firefox ignoresui.useOverlayScrollbars, so measure headed with those switches (Chromium: drop--hide-scrollbars, style::-webkit-scrollbar; Firefox 150:ui.useOverlayScrollbars: 0).- Resolve coordinates against the editor HOLDER's rect (
TEXTAREA_PARENT), not the cell's.TextEditor#refreshDimensions()already placed the holder where the oldabsoluterules resolved, including the CONDITIONAL border compensation (BaseEditor#getEditedCellRect()cancels a 1px shift for a cell drawing its own inline-start border, depending on row headers and rendered columns). A hardcodedcell.left - 1is a pixel off on some columns. - Write the same physical property the old rules wrote:
leftunder LTR,rightunder RTL, and clear the other. Mirroring RTL intoleftassumes the holder is as wide as the cell (list 2px off). The signs are opposite:right: offsetmoves the list's right edgeoffsetpx LEFT of the holder's, so the viewport distance isviewportWidth - holder.right + offset, whileleftisholder.left + offset. A wrong sign moves the flipped RTL list by twice the overhang, the wrong way. HandsontableEditor#htContainerIS the sub-grid'srootElement(a non-root instance uses the element it was given,core.ts), as ishtEditor.rootElementand the elementapplyRootSize()writesheight/overflowon. A style written on one is overwritten by the next writer of the other; the flip methods are the last writer ofposition/top/left.- Guard a document
scrolllistener that follows the cell withhot.isDestroyed, becausegetEditedCell()throws on a destroyed instance.instance.isDestroyed = trueis the LAST assignment inCore#destroy()(core.ts), afterrunHooks('afterDestroy')(which releases the editor's event manager) and after theobjectEachsweep that sets the flag tonull, so the flag is falsy during teardown. The guard covers delivery AFTERdestroy()returns:scrolldispatches asynchronously, so one fired just before teardown can run after it, and an editor whosedestroy()does not release its event manager keeps the listener for good (what threw in the legacy suites, onMultiSelectEditor). Do not justify the guard as "the teardown's scroll arrives before the listener is released"; that sequence does not happen. - Every scroll that moves the cell must RE-CLAMP the list, keeping the list's own scroll.
MultiSelectEditor#followCell()serves the grid'safterScroll*hooks and the page-scroll listener, and runsupdateDimensions()beforerefreshDimensions(); a move without a re-clamp keeps the opening height and hangs past the block's edge (0 of 8 options reachable).updateDimensions()ends withscrollTop = 0and a wheel past the list's last entry spills into the grid or page, so readscrollTopbefore it and write it back after. Measure the height the list WANTS, not the height it has:getDropdownHeight()is the sub-grid's current height, soAutocompleteEditor#limitDropdownIfNeeded()comparesgetTargetEditorHeight()(from the choices andvisibleRows); reading the current height froze the list at its edge size. Gate the restore on the current height being smaller, so an untrimmed list pays noupdateSettings()per scroll event. The sub-grid keeps its holder scroll across thatupdateSettings()(measured onmainandhorizon), so only the multiselect's list needs a manual restore. Decide the vertical flip BOTH ways:DropdownController#updateDimensions()used to only setflippedVertically(harmless once per open, sinceprepare()resets it), but under a scroll follow it kept a list flipped above a cell scrolled back to the top. OnlynoFlip(the filter path) keeps the flip. GuardgetEditedCell(): the capture-phase page-scroll listener runs BEFORE the grid redraws, so the scroll that unrenders the row finds its cell and every later one does not. - Cap the list's HEIGHT to the free space, not only its TOP; the plain
handsontabletype has no trim of its own.AutocompleteEditor(sodropdown) trims to whole rows andMultiSelectEditorclamps its entry count, but baseHandsontableEditoropened at its sub-grid's height and clamped onlytop. Underfixedthe overhang is unreachable (a fixed box adds nothing to page overflow; the holder scrolls only within its given height): 19 of 20 options reachable onmain/horizon, 18 of 20 onclassic, viaelementFromPoint. Cap to the free space on the side the flip chose (capping to the whole block pins the list at the block's top and covers the cell). Keep a one-row floor (#8872).getTargetDropdownHeight()measures the RENDERED sub-grid, so after a cap it returns the capped value; store the wanted height atopen()(a cap does not survive into the next open). Gate the write on the height you WROTE, notgetDropdownHeight()(offsetHeightcomes back a pixel or two off, so that gate never matches and every scroll renders the sub-grid). The gate belongs on EVERY writer and is shared state:appliedDropdownHeightisprotected(not#private) becauseAutocompleteEditor#setDropdownHeight()is a second writer (without the gate a trimmeddropdownlist wrote the same height twice per scroll: 20 writes of203across 10 scrolls, 1 with the gate), andupdateDropdownDimensions()is a third that must record what it wrote or the next trim to that number is skipped as a false match. The RESTORE-path gate readsgetDropdownHeight()and is fine (0 writes per scroll on an untrimmed list) because it compares against the wanted height. The residual pixel or two is absorbed by themaxTopclamp. - Adding the FIRST
eventManagerlistener to an editor may mean itsdestroy()never released it.MultiSelectEditorbuilt anEventManagerand never used it, so itsdestroy()lackedthis.eventManager.destroy(); the scroll follow became its first listener andMemoryLeakTestcounted 88 leaked listeners. Check the editor'sdestroy()tears the manager down before registering a DOM listener. - Run any spec that asserts WHICH SIDE the list opens on on all three themes. Row height sets the list height, so the same grid flips on
horizonand not onmain. Legacy runner defaults tomainand the Playwright local gate is pinned toe2e-main, so a side-pinning spec fails in CI on one theme only (redE2E / UMD (theme: horizon)on #13417). Assert ADJACENCY (the list touches the cell on one side), and leave "which side, and does it escape the grid" to a spec whose grid forces it on every theme. Legacy:--theme=horizon; Playwright:--project=e2e-horizon. - Baseline before calling a legacy failure a stale expectation. Restore the two editor sources from
HEADand rerun (328 specs, 0 failuresproved 11 failures were mine; 6 were real coordinate bugs, 5 stale vertical-flip specs encoding "the list must stay inside the grid", which #8688 says is wrong). - A test helper that walks clipping ancestors must stop at a
fixedelement and clamp to the viewport from there (tests/fixtures/pages/DropdownHeightPage.ts#visibleHeightOfOption). Count reachable options withdocument.elementFromPoint(), not rendered cells.
Categories