Skip to content

feat(widgets): Add controlled mode and state callbacks#9973

Merged
chrisgervang merged 26 commits intomasterfrom
chr/widget-state-callbacks
Apr 7, 2026
Merged

feat(widgets): Add controlled mode and state callbacks#9973
chrisgervang merged 26 commits intomasterfrom
chr/widget-state-callbacks

Conversation

@chrisgervang
Copy link
Copy Markdown
Collaborator

@chrisgervang chrisgervang commented Jan 30, 2026

Summary

Implements the controlled/uncontrolled component pattern for deck.gl widgets, enabling app developers to control, observe, and intercept user interactions. Adds controlled state props, getter methods, and event callbacks.

Closes #9964

Widget Additions

Widget Controlled State Callback Getter
TimelineWidget time, playing onTimeChange, onPlayingChange getTime(), getPlaying()
ThemeWidget themeMode onThemeModeChange getThemeMode()
StatsWidget expanded onExpandedChange getExpanded()
FullscreenWidget onFullscreenChange getFullscreen()
CompassWidget onReset
GimbalWidget onReset
ResetViewWidget onReset
ZoomWidget onZoom
GeocoderWidget onGeocode
LoadingWidget onLoadingChange
SplitterWidget onChange

Widgets with controlled state support both controlled mode (app owns the state via props) and uncontrolled mode (widget manages its own state internally, with initial* props for defaults). In controlled mode, the app is the sole source of truth — the widget fires callbacks but does not update internal state.

Other Changes

  • Renamed defaultIsExpandedinitialExpanded in StatsWidget to align with initial* convention
  • Renamed collapsed/onCollapsedChangeexpanded/onExpandedChange (positive flag naming)
  • TimelineWidget autoPlay respects controlled playing prop (fires onPlayingChange instead of starting timer directly)
  • TimelineWidget play() only auto-resets time to start in uncontrolled mode; in controlled mode the app handles restart logic
  • Added "React Controlled" example tabs to all widget doc pages (Compass, Gimbal, Zoom, Fullscreen, Timeline, Stats, Theme, Splitter)
  • Updated What's New with controlled/uncontrolled widget patterns
  • Added test/apps/controlled-widgets/ manual test app with test matrix README
  • 49 unit tests for controlled vs uncontrolled behavior across all 10 widgets (widget-state.spec.ts)

Test Plan

  • 49 unit tests in widget-state.spec.ts covering controlled/uncontrolled state for all widgets
  • Manual test matrix in test/apps/controlled-widgets/README.md (24 scenarios, all passing)
  • Lint, format, and node test checks pass
  • Tests run in headless Chromium browser

Implement controlled/uncontrolled component pattern for 12 widgets (ThemeWidget, FullscreenWidget, TimelineWidget, ZoomWidget, CompassWidget, GimbalWidget, GeocoderWidget, ResetViewWidget, ViewSelectorWidget, SplitterWidget, StatsWidget, LoadingWidget). Add state props, getter methods, and callbacks. Update widget overview documentation with controlled/uncontrolled mode explanation.

Closes #9964

Co-Authored-By: Claude (global.anthropic.claude-haiku-4-5-20251001-v1:0) <noreply@anthropic.com>
@chrisgervang chrisgervang requested a review from ibgreen January 30, 2026 18:27
@coveralls
Copy link
Copy Markdown

coveralls commented Jan 30, 2026

Coverage Status

No base build to compare — chr/widget-state-callbacks into master

Comment thread modules/widgets/src/splitter-widget.tsx Outdated
Comment thread modules/widgets/src/timeline-widget.tsx
@chrisgervang chrisgervang mentioned this pull request Jan 30, 2026
62 tasks
@chrisgervang chrisgervang added this to the v9.3 milestone Feb 2, 2026
Resolve conflict in stats-widget.tsx: keep _collapsed naming for controlled
mode internal state, use _getStats() method from master.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread modules/widgets/src/view-selector-widget.tsx Outdated
Comment thread modules/widgets/src/fullscreen-widget.tsx Outdated
- Remove redundant _playing assignments in timeline-widget start()/stop()
- Remove redundant updateHTML() calls after super.setProps() in multiple widgets
- Fix IconMenu to support controlled mode via selectedItem prop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread modules/widgets/src/timeline-widget.tsx Outdated
Resolve conflicts in:
- docs/api-reference/widgets/stats-widget.md: Merged documentation formats, added controlled mode props
- modules/widgets/src/reset-view-widget.tsx: Combined multi-view loop with onReset callback
- modules/widgets/src/stats-widget.tsx: Used dropdown button with corrected variable name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread modules/widgets/src/stats-widget.tsx
Comment thread modules/widgets/src/timeline-widget.tsx
Copy link
Copy Markdown
Collaborator

@ibgreen ibgreen left a comment

Choose a reason for hiding this comment

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

I gave this some additional thought and I think supporting application control is a good direction as we build out a more comprehensive UI.

Comment thread docs/api-reference/widgets/compass-widget.md Outdated
Comment thread docs/api-reference/widgets/gimbal-widget.md Outdated
Comment thread docs/api-reference/widgets/splitter-widget.md Outdated
Comment thread docs/api-reference/widgets/stats-widget.md Outdated
Resolve merge conflicts between widget state callbacks feature and
master's widget refactoring (getViewState/setViewState API, ViewLayout
for splitter, RangeInput for timeline, file deletions).

- compass-widget: Integrate onCompassReset callback with master's getViewState pattern
- geocoder-widget: Integrate onGeocode callback with master's flyTo pattern
- reset-view-widget: Integrate onReset callback with master's setViewState
- splitter-widget: Take master's ViewLayout rewrite (branch's controlled split doesn't apply)
- stats-widget: Merge master's FpsIcon collapsed view with branch's controlled collapsed prop
- timeline-widget: Merge master's formatLabel/loop/RangeInput with branch's controlled time/playing
- zoom-widget: Integrate onZoom callback with master's getViewState/setViewState pattern
- Removed view-selector-widget and icon-menu (deleted in master)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread modules/widgets/src/fullscreen-widget.tsx
Comment thread modules/widgets/src/timeline-widget.tsx Outdated
- Rename onCompassReset -> onReset (CompassWidget)
- Rename onGimbalReset -> onReset (GimbalWidget)
- Rename defaultIsExpanded -> initialExpanded (StatsWidget, aligns with initial* convention)
- Rename collapsed/onCollapsedChange -> expanded/onExpandedChange (StatsWidget, positive flag)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread modules/widgets/src/timeline-widget.tsx Outdated
Comment thread modules/widgets/src/timeline-widget.tsx
chrisgervang and others added 4 commits April 1, 2026 14:52
…eWidget

- play() now respects controlled time mode by guarding internal state update
- tick() now syncs Timeline object in uncontrolled mode
- setProps() now syncs Timeline object when controlled time prop changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eWidget

- Fix pseudo-fullscreen path in FullscreenWidget not updating state/UI
  (no fullscreenchange event fires, so manually sync state and re-render)
- Fix controlled playing mode infinite timer loop in TimelineWidget
  (call onPlayingChange(false) when playback naturally ends at max)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover:
- TimelineWidget: controlled/uncontrolled time, playing state, tick end-of-playback,
  Timeline sync, play() controlled mode guard
- StatsWidget: initialExpanded, controlled/uncontrolled expanded toggle
- FullscreenWidget: controlled/uncontrolled state, pseudo-fullscreen path
- CompassWidget/GimbalWidget: onReset callback prop wiring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename from .node.spec.ts to .spec.ts so tests run in headless
browser (standard practice for deck.gl widget tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread modules/widgets/src/timeline-widget.tsx Outdated
Comment thread modules/widgets/src/stats-widget.tsx
chrisgervang and others added 5 commits April 1, 2026 20:50
…ebsite demo

- Use this._playing instead of this.getPlaying() for timer scheduling in
  tick(), so the timer stops immediately when playback ends naturally,
  even before the parent updates the controlled playing prop.
- Update website demo to use initialExpanded (renamed from defaultIsExpanded).
- Add test for controlled-mode end-of-playback timer behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nWidget controlled state

- Add "React Controlled" example tabs to Compass, Gimbal, Zoom (managed viewState),
  Timeline, Stats, Theme (managed widget state), and Splitter (managed views) widget docs
- Remove FullscreenWidget `fullscreen` controlled prop since it cannot
  actually control browser fullscreen state
- Remove corresponding controlled-mode tests for FullscreenWidget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Unit tests (45 total, up from 23):
- ThemeWidget: controlled/uncontrolled toggle, initialThemeMode, getThemeMode
- ZoomWidget: onZoom callback, handleZoom with minZoom/maxZoom constraints
- GimbalWidget: resetOrbitView calls onReset with correct params
- LoadingWidget: onRedraw calls onLoadingChange on state transitions
- GeocoderWidget: flyTo calls onGeocode with coordinates
- ResetViewWidget: resetViewState calls onReset for each view
- TimelineWidget: loop wrap-around behavior
- FullscreenWidget: onFullscreenChange event handler

Example app (examples/get-started/react/controlled-widgets):
- All controlled widgets on one page with live state panel
- MapView demo: Compass, Zoom, ResetView, Fullscreen, Theme, Loading,
  Stats (controlled expanded), Timeline (controlled time + playing)
- Splitter demo: managed views with onChange

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread modules/widgets/src/timeline-widget.tsx
Comment thread modules/widgets/src/fullscreen-widget.tsx Outdated
Comment thread modules/widgets/src/timeline-widget.tsx
…lled mode

In controlled mode, the widget can't atomically reset time and start
playing since those are separate React state updates. The restart-from-
beginning logic now belongs to the app's onPlayingChange handler.

Also switches test app from JSX widget children to widgets prop, adds
test matrix README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5221f14. Configure here.

Comment thread modules/widgets/src/timeline-widget.tsx
chrisgervang and others added 6 commits April 6, 2026 17:39
In controlled mode, autoPlay now calls onPlayingChange(true) to notify
the parent instead of directly starting the timer, which would desync
internal state from the controlled playing prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clarify that in controlled mode the app is the sole source of truth for
time and playing state. Document that autoPlay fires onPlayingChange in
controlled mode, and that restart-from-beginning is the app's
responsibility. Update React Controlled example to demonstrate the
pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… time prop

The constructor was setting the luma.gl Timeline to initialTime/timeRange[0]
even when a controlled time prop was provided. Also fixes autoPlay in
controlled mode to notify parent via onPlayingChange instead of directly
starting the timer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ange

Restores the guard that prevents spurious onFullscreenChange callbacks
and re-renders when the fullscreenchange event fires for unrelated
elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test was calling onFullscreenChange when fullscreen was already
false, so the guard correctly skipped the callback. Fixed by setting
fullscreen=true first, and added a test for the no-op case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chrisgervang chrisgervang merged commit a4438b7 into master Apr 7, 2026
6 checks passed
@chrisgervang chrisgervang deleted the chr/widget-state-callbacks branch April 7, 2026 01:41
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.

feat(mapbox): Add Controlled Mode and State Callbacks to Widgets

4 participants