Conversation
|
A preview of dc677d2 is uploaded and can be seen here: ✨ https://revisit.dev/study/PR1450 ✨ Changes may take a few minutes to propagate. |
There was a problem hiding this comment.
💡 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".
| }); | ||
| }; | ||
|
|
||
| window.addEventListener('keydown', handleKeyDown); |
There was a problem hiding this comment.
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 👍 / 👎.
| event.preventDefault(); | ||
| onSelect(options[index].value); |
There was a problem hiding this comment.
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 👍 / 👎.
| const matches = configKey === pressedKey || ((configKey === 'space' || configKey === ' ') && isSpace); | ||
|
|
||
| if (matches && options[index]) { | ||
| event.preventDefault(); |
There was a problem hiding this comment.
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 👍 / 👎.
| const target = event.target as HTMLElement; | ||
| if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable) { | ||
| return; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| const stopEvent = () => { | ||
| (event as unknown as { __keyMapperHandled?: boolean }).__keyMapperHandled = true; | ||
| if (typeof event.stopImmediatePropagation === 'function') { | ||
| event.stopImmediatePropagation(); |
There was a problem hiding this comment.
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 👍 / 👎.
| useEffect(() => { | ||
| if (disabled || !autoFocus) { | ||
| return undefined; |
There was a problem hiding this comment.
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 👍 / 👎.
| ? String(opt.value) | ||
| : String(opt); | ||
|
|
||
| return optValue.toLowerCase() === targetLower; |
There was a problem hiding this comment.
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 👍 / 👎.
…complete event stream
|
Inline key mapping on the options |
JackWilb
left a comment
There was a problem hiding this comment.
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.
| function verifyKeyMappings( | ||
| basePath: string, | ||
| component: Partial<IndividualComponent>, | ||
| warnings: ParsedConfig<StudyConfig>['warnings'], |
There was a problem hiding this comment.
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?
| return; | ||
| } | ||
|
|
||
| const pressedKey = (event.key || '').toLowerCase(); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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.
| disabled={nextButtonDisabled} | ||
| onClick={() => onNext()} | ||
| px={location === 'sidebar' && checkAnswer ? 8 : undefined} | ||
| rightSection={nextOnEnter ? <Kbd size="xs">↵ Enter</Kbd> : undefined} |
There was a problem hiding this comment.
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.
| {options | ||
| ?.filter((option) => option.key) | ||
| .map((option) => ( | ||
| <button |
There was a problem hiding this comment.
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)} |
There was a problem hiding this comment.
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.

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.