You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: Add internal zoom selection to core chart - #278
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
The reason will be displayed to describe this comment to others. Learn more.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
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.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
With controlled zoomRange={null}, committing a selection can leave Reset visible and focused without an applied range, so it should only be revealed after a range is applied.
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).
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.
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.
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.
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:
check the live region content by using live region's component test-utils method after we expect that announcement method to change, or
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
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
zoomRangeisn't given, the chart keeps the zoomed range itself.minRangeis 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 theChartExtraZoomhelper carries out. The props stay internal (InternalZoomProps), soCoreChartPropsandCartesianChartPropsare 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?
minRangewhen zoom is turned off.To test manually:
#/01-cartesian-chart/zoom.Review checklist
The following items are to be evaluated by the author(s) and the reviewer(s).
Correctness
CONTRIBUTING.md.CONTRIBUTING.md.Security
checkSafeUrlfunction.Testing
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.