Skip to content

Add opt-in lvt-mod:skip-when-typing guard for window keyboard bindings#137

Merged
adnaan merged 8 commits into
mainfrom
keyboard-support
Jun 28, 2026
Merged

Add opt-in lvt-mod:skip-when-typing guard for window keyboard bindings#137
adnaan merged 8 commits into
mainfrom
keyboard-support

Conversation

@adnaan

@adnaan adnaan commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

What

Adds an opt-in lvt-mod:skip-when-typing modifier for lvt-on:window:keydown/keyup bindings. When present, the binding does not fire if document.activeElement is (or is within) an editable element — a text-like <input>, <textarea>, <select>, or [contenteditable]. Button/checkbox/radio inputs are not treated as editable, so shortcuts still fire when one is focused.

Why

Global single-key shortcuts (e.g. j/k to navigate) previously fired even while the user was typing in a text field, so a shortcut letter couldn't coexist with text entry. The guard is opt-in so that bindings which must fire while typing (e.g. Escape to cancel) simply omit the attribute and keep working.

Consumed downstream by prereview's keyboard-support feature (livetemplate/prereview#28).

Tests

tests/event-delegation.test.ts covers: opted-in binding suppressed in textarea/input/contenteditable; fires on non-editable focus; and a binding without the opt-in still fires while a textarea is focused. Full suite green.

🤖 Generated with Claude Code

Global lvt-on:window:keydown shortcuts previously fired even while the user
was typing in a text field, so a single-letter shortcut (e.g. "j") could not
coexist with text entry. Add an opt-in lvt-mod:skip-when-typing attribute:
when present, the binding is suppressed if document.activeElement is (or is
within) an editable element — text-like input, textarea, select, or
contenteditable. Button/checkbox/radio inputs are not treated as editable, and
bindings without the attribute (e.g. Escape-to-cancel) still fire while typing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review: opt-in lvt-mod:skip-when-typing guard

Overall clean — logic is correct, tests cover the key cases, and the opt-in design is the right call. A few things worth knowing:

Shadow DOM gap (minor)
document.activeElement returns the shadow host when focus is inside a shadow root, not the actual inner element. closest() won't pierce the shadow boundary, so isEditableTarget will return false for a focused <input> that lives inside a custom element's shadow DOM — and shortcuts will fire unexpectedly while the user types. If the app uses web components with internal text inputs anywhere, this is worth addressing (e.g. check shadowRoot.activeElement recursively). If not, this is moot.

<select> in EDITABLE_SELECTOR
Reasonable call — selects do typeahead — but worth flagging as a deliberate choice. A user who opens a <select> dropdown and presses j will have that keystroke suppressed for any skip-when-typing binding. That's probably what you want, just make sure callsites are aware.

No functional bugs found. Tests are thorough. Good to merge once the shadow DOM question is answered for your use case.

Addresses the Claude review on #137: document.activeElement returns the shadow
HOST when focus is inside a web component's shadow root, so isEditableTarget
would miss a focused input there and let a shortcut fire while the user types.
Add deepActiveElement(), which descends through shadowRoot.activeElement
(recursively, for nested shadow DOM), and use it in the window-keydown guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review

Good feature overall — the opt-in design is the right call, shadow-DOM traversal is handled correctly, and the test coverage is solid. A few things worth addressing:

Test DOM cleanup (no cleanup → test pollution)

setupWindowKeydown appends nodes to document.body but there's no afterEach (or cleanup return value) to remove them. DOM elements and attached window listeners accumulate across tests. The shadow DOM test is particularly risky — the shadow root persists and could interfere with later tests that inspect document.activeElement or shadowRoot. Suggest adding:

afterEach(() => {
  document.querySelectorAll("[data-lvt-id]").forEach((el) => el.remove());
});

input[type="image"] is missing from the exclusion list

The PR description says button/submit/reset/checkbox/radio are deliberately excluded because they accept activation keys, not text. <input type="image"> is functionally identical to a submit button but isn't in the negation list — so shortcuts would be suppressed while it's focused. Low-frequency edge case, but easy to add:

':not([type="image"])'

Does the guard apply to keyup too?

The PR description mentions lvt-on:window:keydown/keyup but the guard is added in one location and tests only cover keydown. Worth confirming (or adding a test for) that keyup bindings with lvt-mod:skip-when-typing are also guarded.


The core logic (deepActiveElement, closest for ancestor-editable check, opt-in via attribute) is sound. The issues above are all straightforward fixes.

- Exclude input[type="image"] from EDITABLE_SELECTOR — it's a submit-style
  button, not a text field, so shortcuts should fire while it's focused.
- Add a test confirming the skip-when-typing guard also applies to keyup
  bindings (the guard already covers keydown and keyup; this pins it).

The reviewer's third point (test DOM cleanup) is already handled: the suite's
beforeEach/afterEach both reset document.body.innerHTML, which removes appended
nodes including the shadow host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Clean implementation with thorough test coverage. A few observations:

Logic is correct. The opt-in design (guard only fires when lvt-mod:skip-when-typing is present) is the right call — Escape-style bindings keep working without any changes. Shadow DOM traversal in deepActiveElement is necessary and handled correctly.

Minor type imprecisionisEditableTarget accepts EventTarget | null but the only call site passes Element | null (the return type of deepActiveElement). The instanceof Element guard makes it safe, but tightening the parameter type to Element | null would make the intent clearer.

input[type="range"] is included as "editable" (it's not excluded by the :not() chain). Range inputs respond to arrow keys, so this is probably fine — but worth being explicit that it's intentional if it surfaces unexpectedly.

No bugs or security issues. LGTM.

…input

- isEditableTarget now takes Element | null (its only caller passes
  deepActiveElement()'s Element | null result); the instanceof guard is kept.
- Document that input[type="range"] is intentionally treated as editable so
  arrow-bound shortcuts yield to a focused slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Code review — PR #137 (skip-when-typing guard)

Overall this is well-implemented and the test coverage is solid. Two actionable findings:

1. range input behavior is asserted in comments but not tested

The comment explicitly states range inputs are intentionally left in scope (so arrow-key shortcuts are suppressed while a slider is focused). That is a non-obvious policy decision worth pinning with a test. Without one, a future reader who finds the omission surprising may "fix" it and break the intended UX.

2. Test DOM leaks between cases

setupWindowKeydown appends to document.body but never cleans up. Wrappers accumulate across the test suite. This is fine as long as each wrapper ID stays unique (they are here), but an afterEach(() => wrapper.remove()) in the describe block would prevent subtle failures if a future test shares an ID or relies on a clean body.

No bugs or security issues found. The deepActiveElement shadow-DOM traversal is correct, the EDITABLE_SELECTOR exclusions are well-reasoned, and the opt-in design (attribute required to suppress) is the right default.

Adds a test asserting an arrow-bound skip-when-typing shortcut is suppressed
while a range input is focused, so the slider consumes the arrow — pinning the
documented intentional inclusion of range in EDITABLE_SELECTOR.

(The reviewer's second point — test DOM cleanup — is already covered by the
suite's top-level beforeEach/afterEach, both of which reset document.body.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review

Logic: guard is silently skipped when lvt-key is absent

The skip-when-typing check lives inside the hasAttribute("lvt-key") block (lines 750–770 in the merged file), so this markup silently ignores the guard:

<!-- No lvt-key → guard never runs, shortcut fires while typing -->
<div lvt-on:window:keydown="nextFile" lvt-mod:skip-when-typing></div>

That combination is admittedly unusual (a binding that fires on every keydown), but the dependency is invisible to consumers. Two options:

  1. Move the guard outside the lvt-key block so it always applies when the attribute is present.
  2. Log a warning (dev-mode only) if skip-when-typing is set without lvt-key.

Minor gap: contenteditable="plaintext-only"

Chrome supports contenteditable="plaintext-only" as a distinct editable mode. It isn't matched by [contenteditable=""] or [contenteditable="true"], so a focused plaintext-only region won't suppress the shortcut. Probably fine to leave as a follow-up given how rare it is, but worth a note.

Everything else looks good — the shadow-DOM traversal is correct, the opt-in design is the right call, tests cover the key cases, and the selector exclusions for button/checkbox/radio inputs are well-reasoned. The range-input intentional inclusion is clearly documented.

…only CE

Addresses #137 review:
- The guard lived inside the `hasAttribute("lvt-key")` block, so a binding with
  `lvt-mod:skip-when-typing` but no `lvt-key` was silently never guarded. Move
  the guard out so it applies to any keydown/keyup binding that opts in,
  regardless of a key filter (test added).
- Match `contenteditable="plaintext-only"` (a distinct editable mode in Chrome)
  in EDITABLE_SELECTOR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Overall: looks good — the opt-in design is correct and tests are thorough. Two things worth addressing before merge:


1. input[type="file"] is incorrectly treated as editable (dom/event-delegation.ts)

EDITABLE_SELECTOR excludes button/submit/reset/checkbox/radio/image but not file. So when a file-picker input is focused, all lvt-mod:skip-when-typing shortcuts are suppressed — but file inputs don't accept typed text. The exclusion list should include type="file":

'input:not([type="button"]):not([type="submit"]):not([type="reset"])'
  + ':not([type="checkbox"]):not([type="radio"]):not([type="image"])'
  + ':not([type="file"])',

2. input[type="number"] inconsistency with input[type="range"]

The comment explicitly calls out that range is kept in the editable set because arrow keys adjust the slider. The same is true of number inputs (↑/↓ increment the value). If range inputs suppress arrow shortcuts, number inputs probably should too. Worth a deliberate decision either way — right now number is included by default but the rationale comment only mentions range.


No other issues. The deepActiveElement() shadow-DOM traversal is correct, the closest() approach for contenteditable children is right, and the check order (key filter before typing guard) is efficient.

- file inputs are button-like (open a picker, take no typed text) — exclude
  them so shortcuts fire while one is focused, consistent with button/image.
- Clarify the rationale comment that input[type=number] is intentionally
  editable (you type digits and arrows step the value).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review

Clean implementation with solid test coverage. A few things worth noting:

Minor bug: color input unintentionally treated as editable

The exclusion list covers button/submit/reset/checkbox/radio/image/file, but not type="color". A focused color picker would suppress shortcuts with lvt-mod:skip-when-typing even though no text entry happens. Low-impact but worth adding :not([type="color"]) to the exclusion chain alongside file.

Redundant instanceof check in isEditableTarget

The parameter is already typed as Element | null, so node instanceof Element only guards null — node !== null would be clearer. Not a bug, just noisy.

Everything else looks good:

  • Shadow DOM traversal in deepActiveElement is correct (iterates through nested shadow roots)
  • closest() on the deepest active element correctly walks up within the same shadow tree
  • The opt-in design (attribute absent means always fire) is the right default, so Escape-style bindings work without changes
  • select inclusion is intentional and documented
  • Test coverage is thorough: textarea, text input, contenteditable, shadow DOM, button/non-editable, range, keyup, no-lvt-key filter, and no-opt-in cases all covered

The color input exclusion is the only real issue worth fixing before merge.

…get (#137)

- color inputs open a picker and accept no typed text — exclude them from the
  editable set (the last non-text input type, alongside button/file/image/etc).
- isEditableTarget's param is Element | null, so the instanceof Element check
  only guarded null; use node !== null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
@adnaan adnaan merged commit 2276693 into main Jun 28, 2026
6 checks passed
@adnaan adnaan deleted the keyboard-support branch June 28, 2026 03:08
adnaan added a commit to livetemplate/prereview that referenced this pull request Jun 28, 2026
Picks up the merged livetemplate/client#137 (skip-when-typing guard) including
the review hardening: shadow-DOM deep active element, non-text input-type
exclusions (image/file/color), contenteditable=plaintext-only, and the guard
applying independent of lvt-key. Keyboard e2e suite re-verified green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe
adnaan added a commit to livetemplate/prereview that referenced this pull request Jun 28, 2026
…ity (#64)

* Add keyboard shortcuts, help overlay, and composer autofocus

Resolves #28 (review-side keyboard operability).

Every common review action now has a keyboard shortcut, single-sourced from
internal/review/keymap.go: the keyBindings slice renders BOTH the hidden
lvt-on:window:keydown elements and the rows of a "?" help overlay, so the live
bindings and their documentation can't drift.

Shortcuts (all skip-when-typing so they don't fire in a text field):
  j / ↓  next file        k / ↑  prev file
  n / p  next / prev comment
  c      comment on file  f      toggle file tree
  a      all comments     r      show/hide resolved
  .      focus mode       ?      keyboard help
Esc (cancel / close) and Enter (save) round out the help reference.

Details:
- Esc is a guard-free global window binding rendered as a child of <body>, so
  it cancels the composer even while the textarea is focused and works in both
  repo and external mode. (An attribute on <body> itself would be missed: the
  window-binding scan is wrapper.querySelectorAll, which excludes the wrapper.)
- The composer textarea gets lvt-autofocus so the cursor lands there when it
  opens — keyboard users can type immediately.
- New controller handlers: ToggleKeyboardHelp, NextComment/PrevComment
  (step state.VisibleComments in all-comments order, wrap, switch file as
  needed). ClearSelection also closes the help overlay.
- Toolbar "?" button + mobile more-menu item, both dispatch toggleKeyboardHelp.
- Embeds the patched livetemplate client (lvt-mod:skip-when-typing guard).

Relies on the upstream lvt-mod:skip-when-typing primitive. The "/" filter-focus
accelerator is deferred (focusing an always-visible input on keypress needs a
focus-on-command primitive; the filter stays Tab-reachable).

Tests: keymap/controller unit tests (internal/review/keyboard_test.go),
TestTemplateOutputSignature golden regenerated, and a chromedp e2e
(e2e/e2e_keyboard_test.go) covering nav, the typing guard, Esc-to-cancel, and
the help overlay with all four debug signals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Make rendered markdown blocks keyboard-operable

Phase 3 focusability audit: the review flow is already almost entirely native
focusables (line/file-tree/toolbar buttons, base <select>, filter <input>, TOC
<a>, external pins). The one gap was selectBlock on the rendered-markdown block,
a <div class="md-rendered"> that holds arbitrary HTML and so can't be a native
<button>. Give it role="button" tabindex="0" and an Enter keydown bound to
selectBlock, so a keyboard user can focus a rendered block and comment on it —
the keyboard equivalent of clicking it. (Enter only, not Space, which would
fight page-scroll without a custom preventDefault.)

Drag-only sub-region selection (HTML region-select, image area-select) stays
mouse-only by design; the keyboard fallback for those file types is the "c"
file-level comment, already noted in the help overlay.

Adds TestE2E_KeyboardMarkdownBlock (focusable + Enter opens the composer) and
regenerates the template signature golden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Add keyboard comment-lifecycle e2e (save + resolve)

Proves the core of issue #28 end-to-end: a reviewer authors AND saves a comment
with only the keyboard ("c" → type → Tab to Save → Enter, CSV-verified), then
resolves it from the keyboard. Records that focus lands on <body> after the
composer closes — a noted ergonomics follow-up (the user isn't stranded: all
global shortcuts work from body), not a blocker for keyboard operability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Fix keyboard-review issues found in manual testing

Four fixes surfaced while testing the keyboard support on a live binary:

1. Can't switch files after a line comment (the serious one). Saving a LINE
   comment left the composer stuck open with focus trapped in the textarea, so
   every skip-when-typing shortcut was suppressed until reload. Root cause: the
   conditional composer slot inside the non-keyed line-row in the VisibleLines
   range wasn't cleared by the patch when the selection cleared (the file
   composer, in a stable section, closed fine). Fix: key the line-row div with a
   "-composing" marker so it's cleanly replaced when the composer toggles.
   lvt-form:preserve is kept (no draft loss, no per-keystroke round-trips).

2. Pressing "r" with no resolved comments did nothing. Add a Flash state field +
   auto-dismissing toast ("No resolved comments"); ToggleShowResolved sets it
   instead of silently no-op'ing. New ClearFlash handler.

3. "f" did nothing on desktop (the file tree is a persistent sidebar there).
   Add desktop CSS so toggleFiles collapses the sidebar and reveals the hamburger
   as the reopen affordance (FileDrawerOpen means "open" on mobile, "collapsed"
   on desktop — read oppositely per media query).

4. Focus landed on <body> after saving a comment. AddComment (all add-paths) now
   sets ScrollToCommentID to the saved comment, and the comment-card templates
   gained tabindex=-1 + lvt-autofocus on that match, so focus (and scroll) land
   on the saved comment. Heading nav and comment scroll are made mutually
   exclusive (each clears the other) so they don't fight — covered by the
   existing TOC-navigation e2e.

Tests: new unit tests (flash, clearFlash) and e2e
(TestE2E_LineCommentClosesComposerAndNavigates, TestE2E_ShowResolvedFlash,
TestE2E_DesktopFilesToggle) + the comment-lifecycle test now asserts focus lands
on the saved comment. Full browser e2e suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Fix blue background on rendered markdown blocks

Phase 3 added role="button" to the .md-rendered blocks for keyboard
accessibility, but Pico CSS paints every [role=button] as a filled primary
(blue) button — so each rendered markdown block got a blue background and
white text. Drop role="button"; keep tabindex="0" + the Enter keydown, so the
block stays keyboard-focusable and Enter-activatable (no Pico button styling).
Pico does not style bare [tabindex], so the prose renders normally again.

TestE2E_KeyboardMarkdownBlock still passes (asserts tabindex 0 + Enter opens
the composer; no role assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Add keyboard line cursor (arrow keys) for commenting on a line

Commenting on a specific line previously required Tab-ing focus through the
whole UI to a line button. Reassign ArrowUp/ArrowDown from file navigation to a
diff line cursor: CursorUp/CursorDown walk the visible diff lines (skipping fold
markers), and the cursor line is highlighted (.is-cursor), scrolled into view,
and focused (lvt-autofocus) so Enter activates it → the line composer. j/k keep
switching files; the cursor resets when the file changes.

Keymap/help updated (↑/↓ = move line cursor, Enter = comment on cursor line);
new TestE2E_KeyboardLineCursor; the obsolete arrow-switches-files assertion was
removed from TestE2E_KeyboardShortcuts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

* Re-sync embedded client JS from merged client main

Picks up the merged livetemplate/client#137 (skip-when-typing guard) including
the review hardening: shadow-DOM deep active element, non-text input-type
exclusions (image/file/color), contenteditable=plaintext-only, and the guard
applying independent of lvt-key. Keyboard e2e suite re-verified green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant