Skip to content

fix: escape key works in braille mode for subplot exit#593

Merged
nk1408 merged 11 commits intomainfrom
fix-multipanel_plot_escape
Apr 7, 2026
Merged

fix: escape key works in braille mode for subplot exit#593
nk1408 merged 11 commits intomainfrom
fix-multipanel_plot_escape

Conversation

@soundaryakp1999
Copy link
Copy Markdown
Collaborator

Pull Request

Description

Escape key now exits the currently opened subplot and focuses on the list of subplots in multipanel and facet plots even if braille mode is on.

Related Issues

Fixes #574

Changes Made

Earlier behavior: In multipanel and facet plots, the escape key was used to exit the currently opened subplot. This did not work with braille mode on. This PR fixes the issue: Escape exits the subplot and focuses on the subplot list even if braille mode is on. Further, it preserves the state of braille mode along with sonification and text I.E. If the user exits the subplot while braille mode is on and opens a new subplot, braille representation of the new subplot is displayed.

Screenshots (if applicable)

Checklist

  • I have read the Contributor Guidelines.
  • I have performed a self-review of my own code and ensured it follows the project's coding standards.
  • I have tested the changes locally following ManualTestingProcess.md, and all tests related to this pull request pass.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have updated the documentation, if applicable.
  • I have added appropriate unit tests, if applicable.

Additional Notes

@claude
Copy link
Copy Markdown

claude Bot commented Apr 1, 2026

Test comment to verify posting works.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 1, 2026

Code Review

Thanks for the fix! The overall approach is well-reasoned and the inline comments clearly explain the screen-reader-specific constraints. Here are my observations:


Correctness / Potential Bugs

1. Focus stack inconsistency after ExitBrailleAndSubplotCommand

After the command runs, focusStack = [TRACE] while hotkeys scope = SUBPLOT, leaving them out of sync. The correction logic in toggleFocus does handle this indirectly when returning from other modals, but this relies on an implicit invariant. A more explicit fix would push SUBPLOT onto the focusStack inside dismissModalScope (or a new atomic method) so the stack always reflects reality.

2. Direct notifyStateUpdate() call in MoveToTraceContextCommand

Calling notifyStateUpdate() from a Command is unusual -- the Model normally decides when to fire notifications. More critically, enterSubplot() was just called immediately before. Does resetToInitialEntry() or the internal toggleScope already notify observers? If so, this is a double-notification for all registered services (audio, text, braille, highlight). If not, a comment explaining why enterSubplot does not trigger this itself would prevent the call from being removed in a future cleanup.


Code Quality

3. role=application on the Braille wrapper div

This is a broader change than just fixing Escape passthrough. role=application tells screen readers to suppress all browse-mode shortcuts for the entire subtree, not just the textarea. If the braille div ever grows to include non-interactive content (labels, status text), users lose the ability to read it with standard SR navigation commands. Consider placing this attribute directly on the textarea for tighter scoping, or using aria-modal=true on the div as an alternative approach.

4. notifyFocusChange has no cancellation mechanism

Rapid Escape presses could queue multiple deferred firings. A 0 ms delay makes this very unlikely to cause visible issues, but storing the timeout ID and cancelling on re-entry would make the method safer to reuse.


Missing Tests

The checklist notes unit tests were not added. Given this fix targets a multi-step, ordering-sensitive sequence (focus stack manipulation, scope transitions, deferred events), tests for ExitBrailleAndSubplotCommand and the modified MoveToTraceContextCommand would guard against regressions -- the ordering is non-obvious and easy to break silently.


What is Good

  • The JSDoc on dismissModalScope and notifyFocusChange is excellent -- it explains the why behind the ordering for NVDA/JAWS compatibility, which is exactly what prevents future bugs.
  • Scoping EXIT_BRAILLE_AND_SUBPLOT only to BRAILLE_KEYMAP is correct; it does not interfere with the existing MOVE_TO_SUBPLOT_CONTEXT Escape binding in TRACE scope.
  • Preserving BrailleService.isEnabled across Escape is the right UX -- braille state persists when re-entering a subplot.
  • The public isEnabled getter is a minimal, clean addition.

Summary: The fix is on the right track. The three main items to address before merging: (1) the focus stack inconsistency after ExitBrailleAndSubplotCommand, (2) confirming whether the direct notifyStateUpdate() call in MoveToTraceContextCommand causes double-notifications, and (3) reconsidering the scope of role=application on the Braille wrapper div.

- Sync focus stack with hotkeys scope in dismissModalScope by accepting
  a targetScope parameter, preventing stale [TRACE] vs SUBPLOT mismatch
- Add cancellation to notifyFocusChange to prevent stale deferred events
  from rapid repeated calls, and clean up timer in dispose()
- Move role="application" from wrapper div to textarea element to avoid
  suppressing browse-mode navigation for potential sibling content
- Expand comment on notifyStateUpdate() call explaining why enterSubplot
  does not notify observers itself

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 2, 2026

Code Review

Good fix for a real accessibility pain point. The overall approach of deferring the display change event so screen readers can process focus before the braille textarea unmounts is sound. A few items worth addressing before merge.


Bug: Redundant removeLast in dismissModalScope (src/service/display.ts)

The removeLast(focus) call is a no-op because clear() immediately discards the entire stack. The first parameter is effectively unused. Either drop removeLast (since clear already handles cleanup), or drop clear and only pop the modal scope. If the intent is a full reset, the signature simplifies to:

public dismissModalScope(targetScope: Focus): void {
  this.plot.focus();
  this.focusStack.clear();
  this.focusStack.push(targetScope);
}

Architectural concern: Command calling notifyStateUpdate() directly (src/command/move.ts, MoveToTraceContextCommand.execute)

Calling this.context.active.notifyStateUpdate() from a command bypasses the normal flow where the model notifies its own observers. The inline comment explains why it is needed (enterSubplot does not trigger a notification), but it introduces cross-layer coupling between Command and Model.

A cleaner long-term approach would be to have Context.enterSubplot() call notifyStateUpdate() itself, or add a Context.notifyCurrentState() method. For this PR it works, but worth a follow-up issue to move notification responsibility back to the model layer.


Missing test coverage

The PR checklist acknowledges no unit tests were added. The new commands (ExitBrailleAndSubplotCommand, updated MoveToTraceContextCommand) and the new DisplayService methods (notifyFocusChange, dismissModalScope) are testable without a screen reader. Consider adding:

  • Unit test for ExitBrailleAndSubplotCommand.execute() verifying the three-step sequence (dismiss, exit, notify).
  • Unit test for MoveToTraceContextCommand.execute() verifying the braille path is taken only when isEnabled is true.
  • Unit test for notifyFocusChange verifying debounce behavior (rapid calls only fire once).

Minor: role=application on textarea (src/ui/component/Braille.tsx)

The inline comment is good. One thing to verify: role=application on a textarea is technically redundant since textarea already has an implicit widget role and NVDA/JAWS switch to forms mode on focus regardless. The actual fix may come from the focus sequencing changes rather than this attribute. Worth confirming whether removing it changes behavior. If it is genuinely needed, keep it; if not, removing it avoids potential accessibility regressions for users relying on virtual cursor navigation within the textarea.


What is good

  • The setTimeout(0) deferral pattern is the right tool here and is well-documented with clear reasoning.
  • Timer cleanup in dispose() prevents leaks.
  • The three-step ordering in ExitBrailleAndSubplotCommand is clearly explained in the JSDoc.
  • BrailleService.isEnabled is a clean, minimal public getter rather than exposing internal state broadly.
  • Keybinding added to BRAILLE_KEYMAP is the correct location.

Overall the fix addresses a real screen reader compatibility issue. The redundant removeLast call and missing tests are the main things to address before merge.

- Remove redundant removeLast call in dismissModalScope; simplify
  signature to only accept targetScope since the full stack is cleared
- Move notifyStateUpdate() from MoveToTraceContextCommand into
  Context.enterSubplot() so the model layer owns observer notification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 2, 2026

Code Review

Good fix for the Escape-key-in-braille-mode issue. The three-step sequence in ExitBrailleAndSubplotCommand and the role="application" addition are the right approach for screen reader compatibility. A few things worth discussing.

Correctness

notifyStateUpdate() in context.ts affects all users, not just braille users

The new line in enterSubplot() notifies all observers (AudioService, TextService, BrailleService, HighlightService) whenever any user enters a subplot, not just braille users. Previously, exitSubplot() called active.notifyStateUpdate() but enterSubplot() did not, so this fixes a real asymmetry. That said, entering a subplot will now unconditionally play audio and announce text. Manual testing on non-braille multipanel charts is worth doing to confirm this does not feel jarring compared to the old behavior.

Potential double-update when entering a subplot with braille enabled

In MoveToTraceContextCommand.execute(), context.enterSubplot() triggers trace.notifyStateUpdate() which calls BrailleService.update(), then displayService.toggleFocus(Scope.BRAILLE) fires the display event. Those are different channels so there should be no true double-update of braille content — but it is worth verifying that toggleFocus(Scope.BRAILLE) does not indirectly trigger another BrailleService.update() via some internal path.

Design

MoveToTraceContextCommand now takes three constructor args instead of one

Reasonable trade-off for the fix. If this pattern grows, it might be worth exploring whether enterSubplot() could emit an event that BrailleService/DisplayService react to, keeping commands thinner. Not a blocker.

dismissModalScope is a destructive public method

The method clears the entire focus stack. That is correct for the escape-from-braille flow, but a future caller passing the wrong targetScope could silently leave the hotkeys scope out of sync with the focus stack. A JSDoc note warning callers that the hotkeys scope must already be set to targetScope before calling this would help.

Missing Tests

No tests are added for:

  • ExitBrailleAndSubplotCommand.execute() — the three-step sequence is the heart of this fix
  • dismissModalScope() — stack clear + push + plot focus
  • notifyFocusChange() — deferred emit, cancellation of pending timer, cleanup on dispose

The setTimeout(0) behavior is straightforward to cover with Jest fake timers and hard to reason about without a test.

Minor

  • role="application" on the textarea (not the wrapper div) is the right placement; the comment explains the trade-off clearly.
  • pendingFocusChangeTimer cleanup in dispose() is correct.
  • The second and third commit messages explain the rationale for ordering changes well.

Revert notifyStateUpdate() from Context.enterSubplot() as it caused
unwanted audio on subplot entry. Update braille service directly from
MoveToTraceContextCommand instead — only braille needs refreshing.

Add unit tests for ExitBrailleAndSubplotCommand, MoveToTraceContextCommand,
and DisplayService (dismissModalScope, notifyFocusChange debounce/dispose).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 2, 2026

Code Review (Updated)

The PR has been significantly improved since earlier reviews — tests are now included and the redundant removeLast in dismissModalScope is gone. Here is a focused review of the current diff.


What's well done

  • Tests added: braille-escape.test.ts covers call ordering, argument values, and the three-step sequence. display.test.ts covers deferred firing, cancellation, and timer cleanup on dispose. The use of Jest fake timers for the setTimeout(0) logic is exactly right.
  • dismissModalScope is clean: clear → push pattern is simple and correct.
  • notifyFocusChange cancels stale timers: rapid Escape presses won't queue multiple deferred events.
  • role="application" is on the textarea element, not the wrapper div — scoped correctly as the comment explains.
  • Keybinding scoped to BRAILLE_KEYMAP only — does not interfere with the existing Escape binding in TRACE scope.

One remaining architectural concern

MoveToTraceContextCommand calls brailleService.update(state) directly (src/command/move.ts, lines ~215–219).

The inline comment explains the intent: avoid triggering AudioService (unwanted tone on entry) and other observers. That is a valid concern, but the mechanism crosses a layer boundary — a Command is directly invoking a Service's update() method, bypassing the observer chain entirely.

Two lower-risk alternatives worth considering:

  1. Add a dedicated method: e.g. brailleService.refreshDisplay(state) that makes the intent explicit and keeps it separate from the observer-driven path.
  2. Suppress other observers selectively: Have Context.enterSubplot() accept options like { notifyServices: string[] }, so only braille is notified. More invasive, but keeps notification in the model layer.

Either way, a follow-up issue to track this debt would be appropriate. The current approach works; it is just worth keeping the coupling documented.


Minor: state.type === 'trace' guard after enterSubplot()

In MoveToTraceContextCommand.execute(), this.context.state.type is checked for 'trace' after calling enterSubplot(). This guard is defensive and correct, but if enterSubplot() leaves the context in an unexpected state, the braille display silently does nothing. A console.warn in the else branch would help during debugging if this path is ever hit unexpectedly.


Summary

The fix is solid and well-documented. The screen-reader-safe sequencing, timer debouncing, and test coverage are all good. The direct brailleService.update() call from the command layer is the one item worth tracking as a follow-up issue — not a blocker for merging, but should not be copied elsewhere before the pattern is revisited.

soundaryakp1999 and others added 2 commits April 1, 2026 20:51
… updates

Add a dedicated refreshDisplay() method on BrailleService to make the
intent explicit when updating braille outside the observer chain. Add
console.warn for the defensive state type guard after enterSubplot().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… updates

Add a dedicated refreshDisplay() method on BrailleService to make the
intent explicit when updating braille outside the observer chain. Add
console.warn for the defensive state type guard after enterSubplot().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 2, 2026

Code Review

Thanks for this fix — the bug (Escape not working in braille mode) is a real accessibility gap and the overall approach is well-reasoned. Here are my findings:


What's working well

  • Comprehensive JSDoc comments throughout, especially the explanation in ExitBrailleAndSubplotCommand documenting the screen-reader ordering constraint. This is exactly the kind of non-obvious context that prevents regressions.
  • role="application" on the braille textarea is the correct, targeted fix for NVDA/JAWS key interception. The comment explaining placement on the <textarea> rather than the wrapper div is appreciated.
  • Good test coverage: the setTimeout(0) deferral behavior, cancellation of pending timers, and cleanup on dispose() are all covered.

Issues / Concerns

1. refreshDisplay JSDoc is misleading (minor)

// src/service/braille.ts
public refreshDisplay(state: TraceState): void {
  this.update(state);
}

refreshDisplay delegates to this.update(state). The JSDoc says this avoids triggering other observers, but update() is a method on BrailleService itself — it never triggers other observers. The observer pattern works because notifyStateUpdate() on the model fans out to each registered observer. Calling brailleService.update(state) directly is already isolated.

The rename to refreshDisplay is fine as a clearer public API name, but the doc comment should be corrected: the reason audio/text are not triggered is that the model's notifyStateUpdate() is not called, not because this method bypasses other services.

2. Layer boundary: commands now hold service references

MoveToTraceContextCommand now takes BrailleService and DisplayService as constructor parameters, and ExitBrailleAndSubplotCommand takes DisplayService. Per the MVVC architecture in CLAUDE.md, commands call into the model (via context), not services directly.

The comment in execute() explains the pragmatic reason — calling notifyStateUpdate() would fire all observers including audio, which is undesirable on subplot entry. This trade-off is reasonable, but it should be documented as a deliberate architectural exception so future contributors don't copy the pattern indiscriminately.

3. Missing test: the console.warn branch in MoveToTraceContextCommand

} else {
  console.warn('[MoveToTraceContextCommand] Expected trace state after enterSubplot, got:', state.type);
}

No test covers the case where state.type !== 'trace' after enterSubplot. Suggested addition to braille-escape.test.ts:

test('does not call refreshDisplay when state type is not trace', () => {
  const context = createMockContext({ state: { type: 'subplot', empty: false } });
  const brailleService = createMockBrailleService(true);
  const displayService = createMockDisplayService();
  const command = new MoveToTraceContextCommand(context, brailleService, displayService);
  command.execute();
  expect(brailleService.refreshDisplay).not.toHaveBeenCalled();
});

4. console.warn in production code

The [MoveToTraceContextCommand] warn is left in production code. DEBUGGING.md mentions a conditional logger pattern (logger.debug / logger.warn) for production. If the project already uses such a logger, this should go through it rather than calling console.warn directly.

5. notifyFocusChange — document the post-dispose() contract

The setTimeout(0) pattern is well-implemented and covered by tests. One small nit: the JSDoc doesn't mention that callers must not invoke notifyFocusChange after dispose(). Adding a single @remarks or @throws line would make the expected lifecycle explicit.


Nits

  • In test/command/braille-escape.test.ts, the as any cast on mock functions (e.g. jest.fn(() => ...) as any) could be replaced with jest.fn().mockImplementation(() => ...) to preserve type safety.
  • The tests in test/service/display.test.ts re-create DisplayService in every test case rather than using beforeEach. Not wrong, but a shared setup reduces boilerplate.

Summary

The fix is sound and well-tested. The role="application" change + ExitBrailleAndSubplotCommand + deferred notifyFocusChange together form a coherent, screen-reader-aware solution.

Before merging, please:

  1. Correct the refreshDisplay JSDoc to accurately describe why other services are not triggered.
  2. Add a test for the state.type !== 'trace' branch in MoveToTraceContextCommand.
  3. Decide whether console.warn should use the project's logger utility.

The architectural exception (commands holding service references) is acceptable given the observer-chain constraints, but a brief inline comment marking it as intentional would help future contributors.

soundaryakp1999 and others added 3 commits April 3, 2026 00:09
- Correct refreshDisplay JSDoc: other services are not triggered because
  the model's notifyStateUpdate() is not called, not because this method
  bypasses observers
- Add architectural exception comment on MoveToTraceContextCommand
  documenting why it holds service references directly
- Add test for state.type !== 'trace' branch (console.warn path)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…scape

# Conflicts:
#	src/ui/component/Braille.tsx
@claude
Copy link
Copy Markdown

claude Bot commented Apr 3, 2026

Code Review

PR: fix: escape key exits current subplot in multipanel and facet plots even if braille mode is on

This is a solid accessibility fix with well-documented ordering logic for screen-reader compatibility. The core approach is sound. Below are observations and suggestions before merging.


Positive Observations

  • Excellent JSDoc throughout — comments explain why the dismissModalScope → exitSubplot → notifyFocusChange ordering matters for NVDA/JAWS, not just what it does. This prevents future regressions.
  • Keybinding scoped to BRAILLE_KEYMAP only — no collision with the existing Escape binding in TRACE_KEYMAP.
  • notifyFocusChange cancels stale timers correctly via pendingFocusChangeTimer and cleans up in dispose().
  • Test coverage for call ordering, argument values, enabled/disabled branching, and timer cancellation is thorough.

Issues

Correctness

C1. refreshDisplay JSDoc is inaccurate (src/service/braille.ts)

The doc says the method avoids triggering "other observers," but this.update(state) calls a method directly on BrailleService — it cannot trigger other services regardless. The real reason other services are not triggered is that the model's notifyStateUpdate() is not called. This conflation could mislead future contributors.

Suggested correction: describe it as bypassing notifyStateUpdate() on the model so the notification does not fan out to AudioService, TextService, and HighlightService — which would cause an unwanted tone on subplot entry.

C2. Verify no double-update path in MoveToTraceContextCommand

After context.enterSubplot(), both brailleService.refreshDisplay(state) and displayService.toggleFocus(Scope.BRAILLE) are called. Please confirm toggleFocus(Scope.BRAILLE) does not internally trigger a second BrailleService.update() call via listener chain. If they are independent, a brief confirming comment would prevent accidental collapse in a future refactor.


Architecture

A1. Commands holding Service references — create a tracking issue

The warning comment in source code is good, but without a GitHub issue on record the exception can silently spread to other commands.

A2. dismissModalScope has no guard against focus stack / hotkeys scope mismatch

focusStack.clear() is destructive. If a future caller passes a targetScope that does not match the actual hotkeys scope, they silently diverge. A dev-mode assertion would catch this early:

if (process.env.NODE_ENV !== 'production' && hotkeys.getScope() !== targetScope) {
  console.warn('[DisplayService.dismissModalScope] targetScope does not match hotkeys scope');
}

A3. Raw console.warn in production code (src/command/move.ts)

Per DEBUGGING.md, production logging should use the project's conditional logger utility rather than raw console.warn, which ships in production bundles.


TypeScript

T1. as any casts in test mocks (test/command/braille-escape.test.ts)

Several mocks use jest.fn(() => ...) as any. Per the project's no-any policy in CLAUDE.md, use jest.fn().mockImplementation(...) or jest.MockedFunction<typeof fn> instead.


Tests

T2. No unit test for refreshDisplay delegating to update()

A single test confirming that refreshDisplay passes its state argument to update() would complete coverage of this new public method.


Accessibility

AC1. Confirm role="application" on <textarea> is empirically necessary

A <textarea> has an implicit widget role; NVDA and JAWS typically switch to forms mode on focus by default. Please verify empirically that removing role="application" while keeping the focus-sequencing changes actually breaks the Escape passthrough. If it is not needed, removing it avoids a risk of virtual-cursor users losing browse-mode keystrokes inside the textarea.


Summary

Severity Item
Medium C1: refreshDisplay JSDoc is inaccurate
Low C2: Verify no double-update via toggleFocus
Low A1: Track command-holds-service-reference as a follow-up issue
Low A2: No guard against focus stack / hotkeys scope mismatch
Low A3: console.warn should use project logger
Low T1: as any casts in test mocks violate no-any policy
Low T2: No unit test for refreshDisplay
Low AC1: Confirm role="application" is empirically necessary

Recommendation: Approve with minor revisions. The fix is architecturally sound and the screen-reader safety rationale is well-justified. Please address C1 and T1 before merging, and verify AC1 empirically. The remaining items are acceptable as follow-up issues.

Replace jest.fn(() => ...) as any with jest.fn<() => void>().mockImplementation(...)
and use Record<string, unknown> for mock factory overrides to comply with
the project's no-any policy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 3, 2026

PR Review: Escape key exits subplot when braille mode is active

Good fix overall - the root cause is clear, the solution is thoughtfully documented, and the test coverage is solid. Here are my observations:

Architecture

Deliberate MVVC exception in commands

MoveToTraceContextCommand and ExitBrailleAndSubplotCommand now hold direct references to BrailleService and DisplayService. The architectural note is helpful. One alternative worth considering: add a method on Context (e.g. enterSubplotWithBraille()) that encapsulates the conditional refresh, keeping commands thin and services out of the command layer. If the team agrees the current approach is acceptable as a one-off, the inline justification comment is sufficient.

refreshDisplay is a pass-through alias

The method refreshDisplay(state) just calls this.update(state) with no additional behavior. It is fine as an intentional public API boundary, but a doc comment clarifying "called manually, not from the observer chain" would help future readers understand why it exists separately from the observer-based update method.

Potential Bug

ExitBrailleAndSubplotCommand executes unconditionally

The command is only mapped in BRAILLE_KEYMAP so it should only fire in braille scope. However, the command body does not guard against braille mode being disabled. If scopes are ever entered or exited in an unexpected sequence, the deferred focus change event could fire unnecessarily. Low risk given current wiring, but worth a defensive guard or a comment explaining the assumed invariant.

Code Style Issue

console.warn in production code

Per DEBUGGING.md, console logs should be removed before commit. In src/command/move.ts inside MoveToTraceContextCommand.execute(), there is a console.warn for the unexpected-state branch. Either remove it or replace with a project logger utility to avoid leaking internal state info in production.

Accessibility

role="application" on the braille textarea

This is the correct technique to prevent NVDA/JAWS from intercepting Escape. The inline comment is clear and well-scoped. One suggestion: add a note referencing the specific screen reader versions validated against (e.g. NVDA 2024.x, JAWS 2024) so future maintainers know the compatibility baseline.

Test Coverage

The unit tests in braille-escape.test.ts and display.test.ts are well-structured: call ordering, deferred timer behavior, and dispose-cancels-pending-timer are all covered.

Minor gap: There are no direct tests for BrailleService.isEnabled or BrailleService.refreshDisplay. Since refreshDisplay just delegates to update, a smoke test verifying that delegation would help catch regressions if update is ever refactored.

Summary

The console.warn is the only item I would flag for cleanup before merge. The rest are suggestions:

  • Bug fix correctness: Looks correct
  • Architecture: Justified exception, documented - acceptable
  • Code style: Remove console.warn before merge
  • Tests: Good coverage, minor gap in BrailleService direct tests
  • Accessibility: Solid - role="application" is the right call

@soundaryakp1999 soundaryakp1999 changed the title fix: escape key exits current subplot in multipanel and facet plots even if braille mode is on fix: escape key works in braille mode for subplot exit Apr 3, 2026
Replace console.warn with a defensive comment explaining the invariant.
Update test to verify refreshDisplay is skipped without asserting on
console output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 3, 2026

Code Review

Good fix for a real accessibility gap. The PR is well-documented and includes meaningful tests. Here are my observations.

What Works Well

  • Clear architectural commentary — the JSDoc notes on MoveToTraceContextCommand and ExitBrailleAndSubplotCommand explaining why the pattern deviates from the norm are valuable and should stay.
  • Proper resource cleanuppendingFocusChangeTimer is correctly cleared in dispose(), preventing memory leaks and post-dispose callbacks.
  • Deferred focus notification — the setTimeout(0) approach in notifyFocusChange to give NVDA/JAWS a frame to process focus before React unmounts the textarea is the right call for screen reader compatibility.
  • Test structure — ordering assertions in ExitBrailleAndSubplotCommand tests are thorough and well-organized.

Issues and Suggestions

1. Missing test: refreshDisplay called when braille is enabled and state is a trace

test/command/braille-escape.test.ts verifies that refreshDisplay is NOT called when state type is not 'trace', but there is no test asserting it IS called in the happy path (braille enabled + state.type === 'trace'). The default mock context already has state: { type: 'trace', empty: false }, so the 'toggles braille focus when braille is enabled' test exercises this code path — but it never asserts on brailleService.refreshDisplay. A test like the following should be added:

test('calls refreshDisplay when braille is enabled and state is a trace', () => {
  const context = createMockContext();
  const brailleService = createMockBrailleService(true);
  const displayService = createMockDisplayService();

  const command = new MoveToTraceContextCommand(context, brailleService, displayService);
  command.execute();

  expect(brailleService.refreshDisplay).toHaveBeenCalledWith(context.state);
});

2. refreshDisplay is a thin wrapper with no independent value

BrailleService.refreshDisplay is a public alias for the existing update method. The real purpose — bypassing the observer fan-out — lives at the call site in MoveToTraceContextCommand, not in BrailleService. Consider either making update public directly, or renaming refreshDisplay to something that communicates intent more clearly (e.g. applyTraceState). The comment explaining why it is called this way should travel with the call site, not just the definition.

3. Architectural deviation deserves a TODO or issue link

The MoveToTraceContextCommand JSDoc correctly flags this as a "deliberate exception" to commands only touching the model layer. Without a tracking issue, this pattern risks being copied without the same justification. Consider adding a // TODO: xability/maidr#NNN comment linking to a follow-up issue for the right long-term fix (e.g. a targeted BrailleService event that fires on subplot entry without triggering audio).

4. role="application" scope and regression risk

Adding role="application" to the textarea tells screen readers to pass all keyboard input to the web app, including screen-reader-specific shortcuts users rely on. Placing it on the <textarea> rather than the wrapper div is the right call, as noted in the code comment. A few follow-up questions worth considering:

  • Has this been tested with JAWS in addition to NVDA? Their behavior with role="application" can differ significantly.
  • If a user focuses the textarea and uses a screen reader shortcut that normally exits a form field, does EXIT_BRAILLE_AND_SUBPLOT handle it gracefully, or could it attempt to exit the subplot when the user intends a screen-reader action?

This trade-off is worth documenting in the PR description or a follow-up issue.

5. DisplayService now stored in CommandFactory for all scopes

All CommandFactory instances now carry a reference to DisplayService even for scopes that never use EXIT_BRAILLE_AND_SUBPLOT. Not a bug, just worth noting if CommandFactory is ever split by scope in the future.

Summary

Category Verdict
Bug fix correctness Looks correct
Architecture Deliberate deviation, adequately documented
Test coverage Good overall, missing one positive assertion (item 1)
Accessibility Needs JAWS verification for role="application"
Resource cleanup Correct

The missing refreshDisplay assertion (item 1) is the only concrete gap. The rest are suggestions or questions worth discussing before merge.

@soundaryakp1999
Copy link
Copy Markdown
Collaborator Author

I have addressed the comments by claude bot and tested the functionality of the fix locally with both JAWS and NVDA. @jooyoungseo @nk1408 I believe it should be ready to merge.

@nk1408
Copy link
Copy Markdown
Collaborator

nk1408 commented Apr 7, 2026

Looking good to me and works as expected!

@nk1408 nk1408 merged commit e30144a into main Apr 7, 2026
8 checks passed
@nk1408 nk1408 deleted the fix-multipanel_plot_escape branch April 7, 2026 17:47
@xabilitylab
Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 3.59.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@xabilitylab xabilitylab added the released For issues/features released label Apr 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released For issues/features released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: escape key does not exit facet subplots and multipanel subplots when braille mode is on

3 participants