Skip to content

keyboard input controls supported with keymapper - #1450

Open
A7700 wants to merge 6 commits into
devfrom
al/1448-keyboard-inputs
Open

A7700 wants to merge 6 commits into
devfrom
al/1448-keyboard-inputs

Conversation

@A7700

@A7700 A7700 commented Sep 1, 2026

Copy link
Copy Markdown

Does this PR close any open issues?

Closes #1448

Give a longer description of what this PR addresses and why it's needed

Custom keyboard controls are not easily accessible in the config files but are really usefull especially when buttons are involved in the questions. Thie PR enables the user to define a mapping or a list in the json to link buttons to keys on the keyboard and on keypress the linked button is selected.

@A7700
A7700 marked this pull request as draft September 1, 2026 07:29
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

A preview of dc677d2 is uploaded and can be seen here:

https://revisit.dev/study/PR1450

Changes may take a few minutes to propagate.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d64e57b0e0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/components/response/KeyMapper.tsx Outdated
});
};

window.addEventListener('keydown', handleKeyDown);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent one keypress from answering multiple responses

When a questionnaire contains two visible button responses that reuse a key, each KeyMapper installs its own window listener, so the same event invokes both onChange callbacks and silently answers both questions. Since the schema permits overlapping mappings and ResponseBlock renders all visible responses together, keyboard ownership must be scoped to one focused/current response or coordinated centrally.

Useful? React with 👍 / 👎.

Comment thread src/components/response/KeyMapper.tsx Outdated
Comment on lines +52 to +53
event.preventDefault();
onSelect(options[index].value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve Enter mappings against nextOnEnter

When a Study Config enables nextOnEnter and maps Enter to a button, this handler selects the option while the separate window listener in NextButton invokes Check Answer or Next from the same event. preventDefault() does not stop that listener, so it can validate stale form state, consume a training attempt, or advance immediately; the two keyboard behaviors need exclusive coordination or the conflicting configuration must be rejected.

Useful? React with 👍 / 👎.

Comment thread src/components/response/KeyMapper.tsx Outdated
Comment on lines +49 to +52
const matches = configKey === pressedKey || ((configKey === 'space' || configKey === ' ') && isSpace);

if (matches && options[index]) {
event.preventDefault();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore modifier chords when matching plain keys

When a letter or arrow is mapped, modifier shortcuts containing that key also match because only event.key is checked. For example, Ctrl+R or Cmd+R with an r mapping changes the participant's answer and preventDefault() blocks reload; similarly Ctrl+F, Ctrl+S, or Alt+Arrow can be intercepted. Ignore Ctrl, Meta, and Alt chords unless modifier-aware mappings are explicitly supported.

Useful? React with 👍 / 👎.

Comment thread src/components/response/KeyMapper.tsx Outdated
Comment on lines +20 to +22
const target = event.target as HTMLElement;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve keyboard activation of focused controls

When Enter or Space is mapped and focus is on an ordinary button such as Next, Previous, Check Answer, or Clear Selection, the target is not excluded here. The global handler therefore selects an answer and calls preventDefault(), suppressing the focused button's normal keyboard-generated click. Ignore buttons, links, and other interactive controls outside the mapped radio cards before handling the shortcut.

Useful? React with 👍 / 👎.

@A7700
A7700 marked this pull request as ready for review September 2, 2026 08:39
@A7700 A7700 linked an issue Sep 2, 2026 that may be closed by this pull request

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb254dc26e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/components/response/KeyMapper.tsx Outdated
const stopEvent = () => {
(event as unknown as { __keyMapperHandled?: boolean }).__keyMapperHandled = true;
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve mapped keydowns in participant instrumentation

Whenever a configured shortcut matches, this capture-phase stopImmediatePropagation() prevents the existing StepRenderer window listener from recording the keydown in windowEvents; only the later keyup is retained. Studies that analyze participant keyboard interactions or timing therefore receive an incomplete event stream for every key-mapped answer. Coordinate the competing handlers without suppressing the instrumentation listener.

Useful? React with 👍 / 👎.

Comment on lines +23 to +25
useEffect(() => {
if (disabled || !autoFocus) {
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only autofocus when a mapping is configured

ButtonsInput mounts KeyMapper for every buttons response, but this effect runs without checking keys; after 50 ms it blurs the active control and focuses the empty tabIndex={-1} container even for existing configs with no keyMapping. Those responses consequently lose their normal radio focus and arrow-key navigation, and mixed questionnaires can have another active response defocused. Remove this autofocus or limit it to configured mappings without blurring unrelated controls.

AGENTS.md reference: AGENTS.md:L41-L42

Useful? React with 👍 / 👎.

Comment thread src/components/response/KeyMapper.tsx Outdated
? String(opt.value)
: String(opt);

return optValue.toLowerCase() === targetLower;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match configured option values exactly

Object mappings lowercase option values before comparison, although StringOption.value is the exact string stored as the participant's answer and the schema permits case-distinct values. With options valued foo and FOO, for example, { "x": "FOO" } selects and stores the first foo option instead of the explicitly mapped value. Keep key matching case-insensitive if desired, but compare mapping targets to option values exactly.

Useful? React with 👍 / 👎.

@JackWilb

JackWilb commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Inline key mapping on the options
Use a kbd element on the button (allow hiding with a config prop)
Keystroke vs click logging in track. These need to be logged as keystrokes in trrack (maybe new issue?)
Parser error for invalid key maps (Any combo, with + between, verify that they're valid keys) — need to choose what the valid keys are called, is there some standard? (https://revisit-nsf.slack.com/archives/C03NLALH0JJ/p1788445984338849)

@JackWilb JackWilb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the inline key mapping and the key hints. I rechecked the latest commits and am requesting changes because the current implementation still has a few correctness and accessibility problems:

  • Invalid mappings are warnings, so configs such as "14" still run instead of failing in the parser.
  • Shift+X-style combinations are not implemented consistently between validation and runtime.
  • Shortcuts stop working after a participant focuses a visible option, and multiple visible responses use first-listener-wins ownership.
  • A mapped Enter can also trigger the Next/Check Answer handler.
  • The new Next hint changes the accessible name and is causing current Chromium failures.
  • Keyboard and click changes are still indistinguishable in Trrack.

The inline comments below describe the smallest fixes I think are needed. Please also add integration coverage for focus ownership, mapped Enter, parser failures/combinations, accessible names, and Trrack interaction source.

Comment thread src/parser/parser.ts Outdated
function verifyKeyMappings(
basePath: string,
component: Partial<IndividualComponent>,
warnings: ParsedConfig<StudyConfig>['warnings'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently records invalid mappings as warnings, so a config containing "14", "RightKeyboardArrow", or a whitespace-only key still loads. The requirement was for a parser error that stops the study. Could this append to errors instead, and validate the agreed grammar there, including empty values, multi-digit numbers, unknown names, and duplicate bindings?

Comment thread src/components/response/KeyMapper.tsx Outdated
return;
}

const pressedKey = (event.key || '').toLowerCase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parser and runtime do not yet share a representation for combinations such as Shift+X. This code compares only event.key and ignores modifier state, so a plain "x" mapping also fires for Shift+X while "Shift+X" itself cannot match. Please parse and match one canonical modifier-plus-key format.

errorProps={{ c: required ? 'red' : 'orange', fz: 'sm', mt: 'xs' }}
style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }}
>
<KeyMapper

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyMapper is mounted as a sibling of the Radio.Card controls. Its focus guard treats those visible controls as external buttons, so after a participant clicks or tabs to an option, mapped shortcuts are ignored. Please give the mapper explicit ownership of the actual response controls (or bind at the response level) and add a focused-option browser test.

if (isKeyMatch(option.key)) {
(event as unknown as { __keyMapperHandled?: boolean }).__keyMapperHandled = true;

if (typeof event.preventDefault === 'function') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A mapped Enter calls preventDefault(), but that does not stop NextButton's separate window listener. The same keydown can therefore select an option and immediately run Check Answer or Next. Please give one handler ownership of Enter (for example, have NextButton ignore defaultPrevented) and cover this with an integration test.

Comment thread src/components/NextButton.tsx Outdated
disabled={nextButtonDisabled}
onClick={() => onNext()}
px={location === 'sidebar' && checkAnswer ? 8 : undefined}
rightSection={nextOnEnter ? <Kbd size="xs">↵ Enter</Kbd> : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This visible Kbd text becomes part of the button's accessible name, changing "Next" to something like "Next ↵ Enter"; current Chromium tests that look for the exact Next button are failing. Please keep the hint visual without changing the accessible name, and make sure the hint is attached to the action Enter actually invokes when Check Answer is active.

Comment thread src/components/response/KeyMapper.tsx Outdated
{options
?.filter((option) => option.key)
.map((option) => (
<button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These clipped buttons are still ordinary focusable controls inside the FocusTrap. They duplicate the visible radio options in the tab order and accessibility tree, and the later autofocus moves focus to an empty div. Please remove the duplicate controls/autofocus and expose the shortcut on the real response controls instead.

>
<KeyMapper
options={orderedOptions}
onSelect={(val) => answer?.onChange?.(val)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyboard selection and clicking both call the same answer.onChange callback here. ResponseBlock then records both through the generic "Update form field" Trrack action, so the provenance graph cannot tell which interaction caused the answer. Please carry the interaction source through the update and add a provenance regression.

@A7700

A7700 commented Sep 10, 2026

Copy link
Copy Markdown
Author

do not use mantine key elements but instead use some small subtle symbols like copy and collapse symbols on mantine Kbd documentation
image

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.

Support keyboard inputs for buttons

2 participants