本文へ移動
cccskills
無料GitHub で公開

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

インストール方法を見る

含まれるファイル(1)

  • SKILL.md15.2 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Handsontable 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)

MethodPurpose
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 BaseEditor from src/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 EventManager for listeners so they are cleaned up automatically.
  • Call super in 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 fixed box is not always laid out in the viewport; the host page decides. An ancestor with transform, translate, rotate, scale, perspective, filter, backdrop-filter, a will-change naming one of those or contain, or contain: paint|layout|strict|content becomes the containing block, and top/left resolve against its padding box. container-type alone (size, inline-size, scroll-state) does NOT anchor a fixed descendant (measured in Chromium 148/151, Firefox 153, WebKit 26.5; only container-type plus an explicit contain: layout does, and then contain is 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 raw getBoundingClientRect() values. Handsontable's own DOM sets none of these, so only the host page matters. Route every coordinate through getFixedContainingBlockRect() (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-dom carries isolation: isolate (styles/base/_base.scss), so the list's z-index: 200 stays confined there: under ht-shadow-dom, or a host that puts the grid in its own z-index layer, the list paints under the host's chrome.
  • Vertical axis measures against the window; horizontal still measures against the grid's workspace. Only flipDropdownVerticallyIfNeeded() and MultiSelectEditor#getAvailableSpace() measure cell.getBoundingClientRect() against getVisibleVerticalRange() (the containing block cut to documentElement.clientHeight, through getVerticalRoom()), never rootWindow.innerHeight. flipDropdownHorizontallyIfNeeded() keeps getWorkspaceWidth() (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() and MultiSelectEditor#getInlineSpace() read the viewport from one helper, getWindowScrollInlineMetrics() (handsontable/src/editors/utils/windowScrollInlineMetrics.ts), add its offset to getEditedCellRect()'s start + width, and measure room against documentElement.clientWidth. That sum counts from the root's inline-start edge (its right edge in RTL), so the RTL offset is clientWidth - rootElement.getBoundingClientRect().right; the LTR offset is rootElement.getBoundingClientRect().left (getTableOffset().left - rootWindow.scrollX ignored 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, not getFixedContainingBlockRect(): a transform/filter/will-change ancestor makes the block scroll with the page and the list is not clipped to it. The block is only the origin the right value is written from. Pinned by tests/e2e/handsontable-editor-list-position.spec.ts (handsontable, dropdown, autocomplete with trimDropdown: false, multiselect: grid inset 600 px from the inline-start edge, transformed container, page scrolled with the list open) and editors/utils/__tests__/windowScrollInlineMetrics.unit.js. The multiselect list flipped above is anchored by bottom (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 with bidi.direction: 2 or layout.scrollbar.side: 3; or scrollbar-gutter: stable both-edges) means the fixed box spans x 15 to the viewport's right edge, not 0 to clientWidth. measureViewportFixedBox() reads an empty position: fixed; left: 0; right: 0 element when innerWidth exceeds clientWidth. Never use documentElement.clientLeft: Chromium adds the <html> border-left to it, Firefox does not, and a reserved scrollbar-gutter leaves it 0. Chromium keeps the root scrollbar on the right on RTL pages; Firefox does unless its UI is RTL (layout.scrollbar.side: 0 follows bidi.direction, not the page). The window-scroll horizontal flip still measures clientWidth from x=0. Headless Playwright has 0 px overlay scrollbars and headless Firefox ignores ui.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 old absolute rules 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 hardcoded cell.left - 1 is a pixel off on some columns.
  • Write the same physical property the old rules wrote: left under LTR, right under RTL, and clear the other. Mirroring RTL into left assumes the holder is as wide as the cell (list 2px off). The signs are opposite: right: offset moves the list's right edge offset px LEFT of the holder's, so the viewport distance is viewportWidth - holder.right + offset, while left is holder.left + offset. A wrong sign moves the flipped RTL list by twice the overhang, the wrong way.
  • HandsontableEditor#htContainer IS the sub-grid's rootElement (a non-root instance uses the element it was given, core.ts), as is htEditor.rootElement and the element applyRootSize() writes height/overflow on. A style written on one is overwritten by the next writer of the other; the flip methods are the last writer of position/top/left.
  • Guard a document scroll listener that follows the cell with hot.isDestroyed, because getEditedCell() throws on a destroyed instance. instance.isDestroyed = true is the LAST assignment in Core#destroy() (core.ts), after runHooks('afterDestroy') (which releases the editor's event manager) and after the objectEach sweep that sets the flag to null, so the flag is falsy during teardown. The guard covers delivery AFTER destroy() returns: scroll dispatches asynchronously, so one fired just before teardown can run after it, and an editor whose destroy() does not release its event manager keeps the listener for good (what threw in the legacy suites, on MultiSelectEditor). 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's afterScroll* hooks and the page-scroll listener, and runs updateDimensions() before refreshDimensions(); a move without a re-clamp keeps the opening height and hangs past the block's edge (0 of 8 options reachable). updateDimensions() ends with scrollTop = 0 and a wheel past the list's last entry spills into the grid or page, so read scrollTop before 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, so AutocompleteEditor#limitDropdownIfNeeded() compares getTargetEditorHeight() (from the choices and visibleRows); 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 no updateSettings() per scroll event. The sub-grid keeps its holder scroll across that updateSettings() (measured on main and horizon), so only the multiselect's list needs a manual restore. Decide the vertical flip BOTH ways: DropdownController#updateDimensions() used to only set flippedVertically (harmless once per open, since prepare() resets it), but under a scroll follow it kept a list flipped above a cell scrolled back to the top. Only noFlip (the filter path) keeps the flip. Guard getEditedCell(): 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 handsontable type has no trim of its own. AutocompleteEditor (so dropdown) trims to whole rows and MultiSelectEditor clamps its entry count, but base HandsontableEditor opened at its sub-grid's height and clamped only top. Under fixed the overhang is unreachable (a fixed box adds nothing to page overflow; the holder scrolls only within its given height): 19 of 20 options reachable on main/horizon, 18 of 20 on classic, via elementFromPoint. 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 at open() (a cap does not survive into the next open). Gate the write on the height you WROTE, not getDropdownHeight() (offsetHeight comes 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: appliedDropdownHeight is protected (not #private) because AutocompleteEditor#setDropdownHeight() is a second writer (without the gate a trimmed dropdown list wrote the same height twice per scroll: 20 writes of 203 across 10 scrolls, 1 with the gate), and updateDropdownDimensions() 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 reads getDropdownHeight() 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 the maxTop clamp.
  • Adding the FIRST eventManager listener to an editor may mean its destroy() never released it. MultiSelectEditor built an EventManager and never used it, so its destroy() lacked this.eventManager.destroy(); the scroll follow became its first listener and MemoryLeakTest counted 88 leaked listeners. Check the editor's destroy() 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 horizon and not on main. Legacy runner defaults to main and the Playwright local gate is pinned to e2e-main, so a side-pinning spec fails in CI on one theme only (red E2E / 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 HEAD and rerun (328 specs, 0 failures proved 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 fixed element and clamp to the viewport from there (tests/fixtures/pages/DropdownHeightPage.ts#visibleHeightOfOption). Count reachable options with document.elementFromPoint(), not rendered cells.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Use when developing or modifying the @handsontable/angular-wrapper package - Angular components with decorators, NgZone performance optimization, and ng-packagr build system

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

Use when testing a local or public URL on a real device via BrowserStack, opening a live browser session on Android, iOS, or desktop browsers, or when the user mentions BrowserStack, real device testing, mobile testing, cross-browser testing, touch testing, or wants to verify behavior on a specific phone, tablet, or browser version. Also trigger when the user says "test this on Android/iPhone/Safari/Chrome mobile", asks to open a live session, or mentions demo-mobile.html, even if they don't mention BrowserStack by name.

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

Use when a source code change needs a changelog entry, or before committing and pushing any bug fix, feature, or behavior change to source code - detecting when entries are required, categorizing changes correctly (added/changed/fixed/deprecated/removed/security), writing user-facing titles, and creating the JSON entry in .changelogs/

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

Query the pre-built code-review-graph knowledge graph (Tree-sitter, whole monorepo) instead of walking call chains with Grep+Read – 2-6x cheaper, and it catches dynamic dispatch that grep misses. Use for ANY task that spans multiple files, even when the user never mentions a graph or asks for it - fixing or tracing a bug ("fix this bug", "why does X happen", "trace this"), exploring unfamiliar code ("how does X work", "who calls X", "what imports Y", "where is X used or handled"), planning or doing a refactor ("rename X", "is it safe to change or remove X", "what would break", "blast radius", "find dead code"), or reviewing changes ("review this PR", "review this branch or diff"). When you are about to Grep for a symbol's callers, callees, or importers, use this skill.

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

Use when working with row or column indexes in Handsontable - translating between physical, visual, and renderable coordinates, using IndexMapper, or debugging index-related bugs where rows or columns appear in wrong positions

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

Use when creating code examples for documentation pages - JavaScript, TypeScript, React, Angular, and Vue variants with proper imports, registration, and license key

日本語の概要は準備中です。原文の説明を表示しています。

handsontable/handsontable2.2万2026年10月12日 更新

handsontable のスキルをすべて見る

このスキルの問題を報告する