Skip to content

feat: Add internal zoom selection to core chart - #278

Open
srungta08 wants to merge 31 commits into
mainfrom
dev-v3-srungta-zoom-keyboard
Open

srungta08 wants to merge 31 commits into
mainfrom
dev-v3-srungta-zoom-keyboard

Conversation

@srungta08

Copy link
Copy Markdown
Member

Description

Adds range selection to the internal core chart zoom. This is the third step towards built-in zoom for cartesian charts, and it doesn't change any public API.

  • A Zoom button enters zoom mode. A cursor slider moves over the plot with the arrow keys, Page Up/Down, and Home/End, or with the step buttons next to it. Enter or Space sets the range start and then the end.
  • In zoom mode, hovering the plot moves the cursor, and two clicks set the range.
  • Exit zoom or Escape cancels the selection, and so does focus leaving the chart. Reset shows the full range again.
  • The selection is announced to screen readers, and focus moves to Reset after zooming and back to Zoom after a reset.
  • The cursor and its step buttons follow the plot on screen in right-to-left pages, on reversed x-axes, and on inverted charts.
  • When zoomRange isn't given, the chart keeps the zoomed range itself.
  • The zoom minRange is now cleared when zoom is turned off, because Highcharts merges axis updates and kept the old value.

The interaction is a small state machine in core/chart-zoom/zoom-state-machine.ts, which the ChartExtraZoom helper carries out. The props stay internal (InternalZoomProps), so CoreChartProps and CartesianChartProps are unchanged. Dragging across the plot comes in a follow-up PR, and the public API in a later one.

Related links, issue #, if available: n/a

How has this been tested?

  • Unit tests:
    • every state machine transition;
    • keyboard selection, the cursor step buttons, two-click selection, presses released outside the plot, cancelled presses, and secondary mouse buttons;
    • focus after zooming, cancelling, and resetting, including when the Zoom button is disabled;
    • announcements, and the cursor's slider values;
    • right-to-left pages, reversed and inverted axes, hidden series, error bars, and controlled and uncontrolled ranges;
    • clearing minRange when zoom is turned off.
  • The full unit suite passes. The documenter and test-utils snapshots are unchanged.

To test manually:

  • Open #/01-cartesian-chart/zoom.
  • Press Zoom, move the cursor with the arrow keys, and press Enter twice to zoom. Then press Reset.
  • Press Zoom again and click twice on the plot instead.
  • Try Escape, the step buttons, and Home/End.
  • Check the uncontrolled chart, and the "Invert chart" and "Hide zoom controls" settings, with RTL and dark mode switched on.
Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Copilot AI 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.

🟡 Changes recommended

Active issues can make zoom selections use the wrong axis or shift user-chosen boundaries, while focus handling fails during controlled zoom, cancellation, and control navigation.

5 open issues
What changed in this PR

Adds internal range selection to core chart zoom, advancing built-in zoom for cartesian charts without changing the public API.

Changes:

  • Adds a zoom state machine, keyboard and two-click selection, cursor controls, and selection overlays.
  • Supports uncontrolled zoom ranges, focus management, screen-reader announcements, and clearing minRange when zoom is disabled.
  • Expands unit coverage and updates the development page for interactive zoom.
File Description
src/​internal/​chart-styles.ts Adds selection overlay styles and cursor spacing constants.
src/​core/​test-classes/​styles.scss Adds selectors for zoom controls and overlays.
src/​core/​styles.scss Styles the cursor, focus outline, and step buttons.
src/​core/​i18n-utils.tsx Resolves zoom labels and formatted announcements.
src/​core/​components/​core-zoom.tsx Adds controls, cursor buttons, and focus handling.
src/​core/​chart-zoom/​zoom-utils.ts Collects cursor stops and calculates navigation steps.
src/​core/​chart-zoom/​zoom-state-machine.ts Defines selection states, transitions, and effects.
src/​core/​chart-zoom/​__tests__/​zoom-state-machine.test.ts Tests transitions and selection sequences.
src/​core/​chart-core.tsx Mounts the cursor and clears disabled zoom’s minRange.
src/​core/​chart-api/​index.tsx Wires zoom interactions and suppresses hover highlights during selection.
src/​core/​chart-api/​chart-extra-zoom.ts Implements selection, overlays, range updates, and announcements.
src/​core/​__tests__/​zoom-messages.ts Provides shared zoom message fixtures.
src/​core/​__tests__/​common.tsx Adds zoom interaction and inspection helpers.
src/​core/​__tests__/​chart-core-zoom.test.tsx Extends core zoom and reset coverage.
src/​cartesian-chart/​__tests__/​cartesian-chart-zoom.test.tsx Tests cartesian zoom interactions, accessibility, and axis direction.
pages/​app/​index.tsx Supplies development-page zoom translations.
pages/​01-cartesian-chart/​zoom.page.tsx Demonstrates interactive controlled and uncontrolled zoom.

Review effort: Balanced

Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-zoom/zoom-utils.ts Resolved
Comment thread src/core/components/core-zoom.tsx Resolved
Comment thread src/core/components/core-zoom.tsx Resolved

Copilot AI 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.

🟡 Changes recommended

Unresolved defects can leave zoom disabled, corrupt selection remapping, snap to incorrect logarithmic-axis boundaries, and remove visible keyboard focus during data updates.

1 open issue
3 previously missed issues

In code that hasn't changed since last review

Medium severity Re-sync zoom availability after re-enabling zoom

src/​core/​chart-api/​chart-extra-zoom.ts:145

Re-enabling zoom can leave Zoom disabled after starting with two stops and adding more data while zoom is off. The render still sees the disabled setting and skips canZoom, while onZoomPropsChange() only reconciles extremes and may not trigger another render. Run the full stop and availability synchronization after updating the settings so Zoom becomes usable without an unrelated redraw.

Medium severity Restore focus when data removes the focused zoom UI

src/​core/​chart-zoom/​zoom-state-machine.ts:275

If an asynchronous data update removes all cursor stops while the slider has keyboard focus, this transition hides the focused slider and removes Exit zoom without requesting a new focus target. Unlike cancellation caused by blur, the user has not moved focus elsewhere, so keyboard and screen-reader users lose their visible focus position. Restore focus to an available visible target when render-driven cancellation hides the focused zoom UI; retain the no-focus behavior when focus has actually left it.

Medium severity Snap pointers by pixel distance on logarithmic axes

src/​core/​chart-zoom/​zoom-utils.ts:58

This raw-value distance is also used to snap plot pointers, but it does not identify the nearest point on a supported logarithmic x-axis. With stops [1, 100, 10000], hovering or clicking at x=40 selects 1 even though 100 is four times closer on screen, so clicks can commit the wrong range boundaries. For pointer snapping, compare the two binary-search neighbours using their xAxis.toPixels() distances; keep numeric-distance matching for value-based remapping.

5 resolved since last review

Review effort: Balanced

Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
@srungta08
srungta08 marked this pull request as ready for review October 29, 2026 11:18
@srungta08
srungta08 requested a review from a team as a code owner October 29, 2026 11:18
@srungta08
srungta08 requested review from taheramr and removed request for a team October 29, 2026 11:19
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/components/core-zoom.tsx Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-zoom/zoom-utils.ts Resolved
Comment thread src/core/styles.scss Resolved
Comment thread src/core/i18n-utils.tsx Resolved
Comment thread src/core/components/core-zoom.tsx Resolved

Copilot AI 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.

🟡 Changes recommended

Unresolved zoom issues incorrectly count null data, block selection cancellation, disrupt focus and screen-reader feedback, and add unnecessary input processing when zoom is disabled.

1 open issue
5 previously missed issues

In code that hasn't changed since last review

Medium severity Announce repeated live-region messages

src/​core/​chart-api/​chart-extra-zoom.ts:327

Pressing Enter twice without moving first announces the start point and then rejects the zero-width range with exactly the same announcement. Assigning that string again does not notify the announcement selector in AsyncStore, so ChartZoomLiveRegion never updates and the rejection is silent. Please make repeated announcements trigger a fresh live-region update so the reducer's attempt to restate the start point is actually announced.

Medium severity Focus the chart when zoom controls are unmounted

src/​core/​chart-api/​chart-extra-zoom.ts:422

If hideZoomControls becomes true while the cursor holds focus, the controls unmount but the selection remains active. Pressing Escape then hides the cursor and queues this button-focus request, with no mounted ChartZoomControls to consume it, so focus has no visible destination. Invoke the chart-focus fallback directly when the controls element is absent instead of leaving a pending button request.

Medium severity Defer cursor-stop computation until Zoom is enabled

src/​core/​chart-api/​chart-extra-zoom.ts:535

This materializes the cursor stops before checking whether zoom is enabled. Every chart installs this listener, and every chart render invalidates the cache, so the first pointer press after a render traverses, deduplicates, and sorts the series points even on charts with zoom disabled. Please defer this work until after the enabled check to avoid adding unnecessary input latency to the default, non-zooming charts.

Medium severity Exclude null-y points when determining Zoom availability

src/​core/​chart-zoom/​zoom-utils.ts:31

getSeriesData() retains points with y === null, so an all-null line at x=0,1,2 enables Zoom even though the chart's no-data logic treats it as empty. Null gaps can also enable Zoom when fewer than three non-null points remain. Skip null-y points here, as the core derived-state and no-data logic already do.

Medium severity Recheck Zoom availability after resetting the range

src/​core/​components/​core-zoom-controls.tsx:61

After an uncontrolled zoom to two adjacent points, this handler captures canZoom === false, so Reset focuses the chart even though restoring the full range enables Zoom. No later focus request moves focus to the enabled Zoom button. Read canZoom from the store after api.resetZoom() so the decision reflects the reset while retaining the chart fallback when a controlled reset leaves Zoom disabled.

1 resolved since last review

Review effort: Balanced

Copilot AI 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.

🔵 Needs a closer look

Human assessment is needed because controlled range resets and no-data chart unmounting can leave focus misplaced or without a valid fallback.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
pressedIconSvg={zoomModeIcon}
disabled={!canZoom && !inZoomMode}
onChange={({ detail }) => (detail.pressed ? api.enterZoomMode() : api.exitZoomMode({ withFocus: true }))}
ariaLabel={inZoomMode ? labels.exitZoomModeButtonAriaLabel : labels.enterZoomModeButtonAriaLabel}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the aria label on a toggle button should not change: "Exit zoom mode, selected" would currently be announced when in zoom mode, which could be confusing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. The toggle no longer sets an aria-label, so its visible "Zoom" text is the name and the state comes only from aria-pressed. It's announced as "Zoom, toggle button", plus "selected" in zoom mode. The two aria-label strings are removed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think "Zoom" is the right aria label (I'm also not 100% certain if it's the right visible label to be honest). The button doesn't toggle zoom mode, it toggles zoom selection/edit mode.

Copilot AI 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.

Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Comment thread src/core/chart-api/chart-extra-zoom.ts Resolved
Clear the pending press click when zoom is turned off, so that hover
and clicks work again if zoom was disabled during a press.
zoomRange: { x: { startValue: 1, endValue: 3 } },
});
expect(getXExtremes()).toEqual({ min: 1, max: 3 });
expect(getChart().findResetZoomButton()).toBe(null);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: should we just check that the entire zoom controls region is not present? Since we don't have test-util method for that - we can use the class names selector. This is cleaner but also ensures that we hide not just buttons - but the entire region, so that there is no extra paddings etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, the test now checks that the whole zoom controls region isn't rendered, by its class name, so it also covers the padding around the buttons.

Copilot AI left a comment

Copy link
Copy Markdown

test.each([
{ name: "left-to-right page", rtl: false, inverted: false, key: KeyCode.pageDown, now: "5" },
{ name: "inverted chart", rtl: false, inverted: true, key: KeyCode.pageDown, now: "5" },
{ name: "right-to-left page", rtl: true, inverted: false, key: KeyCode.pageDown, now: "5" },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we also check inverted + rtl?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added: the paging test now also covers an inverted chart on a right-to-left page.

vi.useRealTimers();
});

test("announces the start point again when Enter is pressed twice on it", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test is very complex and it asserts an internal component mutation rather than the actual behaviour - which can as well be screen-reader/browser dependent. I recommend to drop this test and instead add code comments explaining the purpose of announcementKey (unless we document it already).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, removed this one

The hideZoomControls test now checks that the whole controls region is
not rendered, and the paging test also covers an inverted chart on a
right-to-left page.
The two tests watched the live region in the DOM, which depends on the
component's internals rather than what users hear. A comment on
announcementKey now explains why the live region is keyed by it.
@srungta08
srungta08 requested review from pan-kot and a balanced review from Copilot November 12, 2026 10:50
);
});

test("keeps the clicks that set the range boundaries away from the chart", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: I'd recommend to update this test name so that it describes the ux implication - not technicalities. I believe the point of the test is to make sure that chart's tooltip would not be shown and pinned as result of committing a zoom range.

Copilot AI 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.

🔵 Needs a closer look

Unresolved issues can remap zoom selections twice during nested redraws and leave a nonfunctional Reset button visible when controlled zoom changes are ignored.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

const { wrapper, rerender } = renderChart({ ...defaultProps, zoomRange: zoomedRange, onZoomRangeChange });
const announcement = "Zoom reset. Showing the full data range.";
let announcements = 0;
const observer = new MutationObserver((records) => {

@pan-kot pan-kot Nov 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we check announcements with a mutation observer for this simple test?

I recommend that we either:

  1. check the live region content by using live region's component test-utils method after we expect that announcement method to change, or
  2. use an observer-based util, but de-couple it from this particular test - the util can just output a list of all announcement messages that were received since it ran

inspiration: https://github.com/cloudscape-design/browser-test-tools/blob/e1aa6e4aa065a6f89e48eeea688a8268c8979592/src/browser-scripts/live-announcements.ts#L9

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed this test in the latest commit

observer.disconnect();
vi.useRealTimers();
}
expect(announcements).toBe(2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we check the content of these announcements?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This test is gone now

expect(zoomButton.findDisabledReason()!.getElement()).toHaveTextContent("There is no narrower range to zoom into");
});

test("unpins the tooltip when zoom mode is entered with the keyboard", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we have this test? The tooltip unpins when clicked outside. If it was pinned with the keyboard - one cannot leave it because we lock the focus within.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

done

expect(wrapper.findTooltip()).toBe(null);
});

test("keeps the pressed Zoom button enabled when the data shrinks during a selection", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: I think the actual meaningful behaviour is not that the zoom button is not disabled - but that zoom can still be canceled. I would update the test name to make it clear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

done

Replace the pinned-tooltip test, which no user can reach because a
pinned tooltip traps focus, with one that hides a hovered point's
tooltip when zoom mode starts. Rename the zoom tests that described
internals, such as cursor stops, after their visible effect.
Replace the unmount test that spied on removeEventListener with one
that checks a release after unmount changes nothing, and drop the
cases for consumers that ignore a reset. Cover the arrow keys on an
inverted right-to-left chart, and two presses with touch and on a
right-to-left page. Merge the duplicate Escape tests and remove
repeated steps and unused cleanup.

Copilot AI 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.

🔵 Needs a closer look

Controlled zoom selections reveal and focus Reset even when the consumer has not applied a range, leaving a visible, focusable control with nothing to reset.

No open issues 🎉

Review effort: Balanced

This branch was successfully deployed

1 active (outdated) deployment
dev-pages — d36cf5e8 Deployed Nov 11, 2026 by srungta08 via deploy / deploy #1345
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.

4 participants