Skip to content

fix(components): a field's Esc in the record drawer no longer steps the pane back or restores it from maximized - #28294

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-zmuy4v
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-zmuy4v

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #28156

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:记录抽屉里,在字段内按 Esc(关提及列表、取消单元格编辑、退出动态输入框或放大面)只归该字段:在下层时不再退回上一层,最大化时不再还原。直接按 Esc(焦点不在字段里)仍退一层、仍还原,根层仍关闭抽屉。
  • 为什么改:读者在关联记录里按 Esc 只想关掉字段里的东西,却连带丢了当前层级或最大化宽度。Radix 的对话层在 document 捕获阶段先于任何字段收到 Esc,所以卡片写的那一行 defaultPrevented 守卫只挡得住放大面那种窗口捕获监听,挡不住普通字段;本 PR 让抽屉用分屏面已经在用的同一条判断。
  • 风险与回滚:只改 @object-ui/components 的 navigation-overlay.tsx 一个处理函数。代价:焦点停在文本框里时 Esc 永远不退层(与分屏面今天一致),要先移开焦点或点 ←。回滚即 revert 本 PR。
  • 先看:ui/packages/components/src/custom/navigation-overlay.tsx 的 handleDrawerEscape 与 escapeOwnedByTarget(新增 pane 参数);ui/packages/components/src/__tests__/navigation-overlay-field-escape-28156.test.tsx。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/components/src/__tests__/navigation-overlay-field-escape-28156.test.tsx,预期 1 个文件、19 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

Declared deviation from the card's Wanted. The card asks for if (e.defaultPrevented) return; alone. Measured on main 69ce01d against the installed @radix-ui/react-dismissable-layer 1.1.19, that guard is not enough:

  • The layer listens for keydown on the document in the capture phase (only while it is the highest layer) and calls onEscapeKeyDown from there, then reads defaultPrevented for its own dismiss.
  • So it hears Esc before any React onKeyDown in the record. A probe through the real NavigationOverlay drawer with onBack: an input whose React handler calls preventDefault() logs onBack first and then the field (which sees defaultPrevented already true); a field marked handled from a window capture listener (the shape feat(plugin-detail)!: one record page laid out by its own width; 动态 is the right column's first panel #28116's enlarged face uses) logs the face first and then onBack.
  • With the one-line guard, the window-capture case stops stepping back and the React case still does. On main the composer's mention list (RichTextCommentInput), the grid's InlineEditing and the data table's cell editor all take Esc in a React onKeyDown.

Seat 2 chose option B, the guard plus the split pane's ownership test (seat ruling, 2026-10-31T02:56Z, in the dispatch thread).

The drawer's Esc (handleDrawerEscape). Below the stack's root, or maximized, the drawer's onEscapeKeyDown now:

  1. returns when the key is already handled (defaultPrevented): an enlarged face or anything else that takes the key ahead of the layer;
  2. otherwise holds the sheet open (preventDefault, so the layer does not dismiss the whole drawer);
  3. leaves the key to its target when escapeOwnedByTarget says a text field, an editable region, or an open menu, list box or dialog in the record owns it;
  4. only then restores from maximized or steps back one level.

This is the question the split pane beside a list already asks (useSplitEscape), so the drawer and the split pane now answer Esc the same way.

escapeOwnedByTarget(target, pane?). It takes the pane's own dialog and never counts it as an owner. Every target inside the drawer sits inside the sheet's role="dialog", so without this a button in the record would read as owned and a bare Esc there would stop stepping back. The split pane passes nothing and reads as before.

Cost. With focus in a text field, Esc never steps the pane back or restores it, as in the split pane today. The reader moves focus out of the field or uses ←.

Unchanged. At the root (no onBack, not maximized) the drawer passes no handler and the layer dismisses on Esc as before; this card does not cover the root. The split pane's listener is untouched.

Tests

New: ui/packages/components/src/__tests__/navigation-overlay-field-escape-28156.test.tsx (19), through the real NavigationOverlay (its Radix sheet and its split listener). The record body holds the shapes the fields really take the key in: a cell editor (React onKeyDown, preventDefault), the composer closing its mention list (React onKeyDown, nothing marked), a field inside an enlarged face and the face's own Esc (window capture listener, marks handled).

  • Drawer below the root: Esc in each of the four keeps the level (onBack, setIsOpen and close never called). CONTROL: a bare Esc steps back once and does not close; an Esc on a button in the record steps back too.
  • Drawer maximized: Esc in each of the four keeps it maximized and open. CONTROL: a bare Esc restores, and only the next one closes the root.
  • Split pane beside the list, below the root and maximized: the same four keep the level / the maximized width, and a bare Esc steps back / restores (CONTROL; these held before this PR and are pinned for parity).
Ablations (each through node scripts/ablation-replace.mjs, restore proven blob == HEAD; the test imports the component from source, so no dist leg)

On head e2e6e5e:

  • A0, the drawer handler back to main's (e.preventDefault(); handleEscape();): Tests 8 failed | 11 passed (19), the eight drawer arms (four below the root, four maximized). Controls and split arms green.
  • A1, the defaultPrevented return removed: 2 failed, the enlarged face's own Esc, below the root and maximized.
  • A2, the ownership test removed: 4 failed, the cell editor and the composer, below the root and maximized.
  • A3, owner !== pane removed: 1 failed, the CONTROL that an Esc on a button in the record still steps back.

Acceptance notes

  • Acceptance item 1 (below the root, field keeps the level; bare Esc steps back): pinned in the drawer and in the split pane.
  • Acceptance item 2 (maximized, field keeps it maximized; bare Esc restores): pinned in the drawer and in the split pane.
  • Acceptance item 3 (against the real NavigationOverlay, ablation reds the field case): A0 above.
  • The root drawer is outside this card: there an Esc in a field still dismisses the whole drawer unless something marked it handled ahead of the layer (feat(plugin-detail)!: one record page laid out by its own width; 动态 is the right column's first panel #28116's face does). Noted, not filed: the card scopes the levels below the root and the maximized pane.

Verification

Tests, type-check and the derived gates on e2e6e5e

Head e2e6e5e9, change set vs the merge base 69ce01d7: 3 paths, 251 changed lines.

  • pnpm --dir ui exec vitest run packages/components/ under the verify lock: Test Files 1 failed | 461 passed | 1 skipped (463), Tests 5 failed | 4422 passed | 24 skipped (4451). The one red file was share-filter-sort-spec-parity.test.ts, which reads @objectstack/spec's .d.ts and met a closure built with OS_SKIP_DTS=1 (no declarations). After env -u OS_SKIP_DTS pnpm turbo run build --filter='@object-ui/components^...' (exit 0) it and the new file pass: Test Files 2 passed (2).
  • pnpm --filter @object-ui/components run type-check (tsc --noEmit && tsc -p tsconfig.test.json): exit 0. eslint on the two changed source files: exit 0. pnpm --dir ui run check:new-line-citations: 0 new citation(s), exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 34 commands, each run with its exit code captured before any pipe; --ran reconcile: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. pnpm --dir ui exec vitest run scripts/: Test Files 169 passed | 2 skipped (171).
  • Not run locally, CI's own: the whole ui/ suite (Test UI), the other type-check lanes, the repo-wide scans. Dogfood not owed (no ! in the title, no (narrowing) arm).
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-sdui-manifest.mjs :: exit 0
node scripts/check-sdui-manifest.mjs --self-test :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 0

Generated by Claude Code

…he pane back or restores it from maximized

The drawer's Radix dialog layer hears Esc on the document in the capture
phase, ahead of the record's own handlers, so its onEscapeKeyDown cannot
wait for a field to mark the key handled. It now asks the split pane's
question itself: a key already handled, or pressed where a text field,
an editable region or an open menu, list box or dialog owns it, is not
the pane's. escapeOwnedByTarget learns to skip the sheet's own dialog.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwcrcmDwuHqtrvwstB
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-tesla · session_01L9qibxgfM8p8t3fo6y9eFK

维护者速读

  • 改了什么:记录抽屉里,在字段里按 Esc(关提及列表、取消单元格编辑、离开评论框)只作用于该字段,不再让抽屉退回上一层或从最大化还原。
  • 为什么改:抽屉在捕获阶段先于字段听到 Esc,读者一按就丢失当前层级或最大化宽度(Record pane: Esc that a field already handled still steps the pane back a level (or restores it from maximized) #28156)。
  • 风险与回滚:只改 navigation-overlay.tsx 抽屉的 Esc 处理,分屏路径不变;回滚 = revert 本 PR。代价:焦点在文本框里时 Esc 不会退层(与分屏现状一致)。
  • 先看:navigation-overlay.tsx 的 handleDrawerEscape 与 escapeOwnedByTarget(target, pane)。
  • 两分钟验证:打开记录抽屉 → 进入关联记录 → 在评论框打 @ 再按 Esc,层级保持;再按一次空 Esc,退回一层。
  • 需要你做的:无;本席按 pm-dispatch 复核后自主落地。
  • 席位意见:同意。按席位裁定 B(抽屉沿用分屏的归属判断),比卡上一行守卫更完整,已作为申报偏差写入正文。

席位核对

ACCEPT · head e2e6e5e936f4bc598f23077f2a9fd213e2027b3c · 2026-10-31T04:00Z

  1. 形态: 3 files, +245/−6 (navigation-overlay.tsx, one test file, one root changeset @object-ui/components patch); Fixes #28156 in the body; not on the governed register, under 3000 lines (check-governed-merges --pr 28294).
  2. 前提实测: the dev measured the capture order (dismissable-layer 1.1.19 calls onEscapeKeyDown on document capture, before React field handlers), which disproved the card's one-line guard; option B was ruled by the seat in the card thread before code.
  3. 门禁 check-runs: 55 on the head, 44 success + 11 skipped, 0 failing; mergeable_state clean. Dev ran 34/34 derived gates exit 0 incl. check-sdui-manifest, reconcile 0 NOT-MEASURED.
  4. 接受的偏差: drawer adopts the split pane's escapeOwnedByTarget (declared in the body, with the cost line). Seat read the diff: pane arg is optional, so useSplitEscape's call is unchanged.
  5. 范围外去向: none reported. Skills-docs: not owed (Metadata-protocol: none). Contract review: none owed (no spec/contract surface, Clause-②: no, no new public export).
  6. 落地条款: the maintainer's standing word 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z): seat runs pr_ready + automerge_enable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record pane: Esc that a field already handled still steps the pane back a level (or restores it from maximized)

1 participant