• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 30129854119

24 Jul 2026 10:05PM UTC coverage: 54.295% (-0.02%) from 54.316%
30129854119

push

github

web-flow
fix: mutually exclusive map control menus + consistent split-mode menu styling (#3547)

* fix: make dropdowns mutually exclusive

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* follow up

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

---------

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

7842 of 17364 branches covered (45.16%)

Branch coverage included in aggregate %.

21 of 32 new or added lines in 2 files covered. (65.63%)

6 existing lines in 1 file now uncovered.

15809 of 26196 relevant lines covered (60.35%)

71.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.87
/src/components/src/map/split-map-button.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {ComponentType, useCallback, useMemo, useState, useRef, useEffect} from 'react';
5
import classnames from 'classnames';
6
import {MapControlButton} from '../common/styled-components';
7
import {Delete, Split} from '../common/icons';
8
import ToolbarItem from '../common/toolbar-item';
9
import MapControlTooltipFactory from './map-control-tooltip';
10
import MapControlToolbarFactory from './map-control-toolbar';
11
import {MapControlItem, MapControls, MapState} from '@kepler.gl/types';
12
import {MapSplitMode} from '@kepler.gl/constants';
13

14
SplitMapButtonFactory.deps = [MapControlTooltipFactory, MapControlToolbarFactory];
7✔
15

16
interface SplitMapButtonIcons {
17
  delete: ComponentType<any>;
18
  split: ComponentType<any>;
19
}
20

21
export type SplitMapButtonProps = {
22
  isSplit: boolean;
23
  mapIndex: number;
24
  onToggleSplitMap: (index?: number) => void;
25
  onSetMapSplitMode?: (payload: {mapSplitMode: MapSplitMode}) => void;
26
  onToggleMapControl?: (control: string) => void;
27
  actionIcons: SplitMapButtonIcons;
28
  readOnly: boolean;
29
  mapControls: MapControls;
30
  mapState?: MapState;
31
};
32

33
const SwipeCompareIcon: React.FC<{height?: string}> = ({height = '18px'}) => (
7!
UNCOV
34
  <svg height={height} viewBox="0 0 16 16" fill="currentColor">
×
35
    <path
36
      fillRule="evenodd"
37
      clipRule="evenodd"
38
      d="M2 2h12v12H2V2zm1 1v10h10V3H3z"
39
    />
40
    <path d="M7.5 3v10h1V3z" />
41
    <path d="M5 8l2-2v4l-2-2z" />
42
    <path d="M11 8l-2-2v4l2-2z" />
7✔
43
  </svg>
44
);
45

46
const SPLIT_MODE_OPTIONS = [
47
  {id: MapSplitMode.SINGLE_MAP, label: 'tooltip.singleView'},
48
  {id: MapSplitMode.DUAL_MAP, label: 'tooltip.dualView'},
49
  {id: MapSplitMode.SWIPE_COMPARE, label: 'tooltip.swipeView'}
50
];
51

52
function SplitMapButtonFactory(
14✔
53
  MapControlTooltip: ReturnType<typeof MapControlTooltipFactory>,
54
  MapControlToolbar: ReturnType<typeof MapControlToolbarFactory>
55
) {
56
  const defaultActionIcons = {
57
    delete: Delete,
58
    split: Split
14✔
59
  };
60

61
  /** @type {import('./split-map-button').SplitMapButtonComponent} */
62
  const SplitMapButton: React.FC<SplitMapButtonProps> = ({
63
    isSplit,
64
    mapIndex,
28✔
65
    onToggleSplitMap,
66
    onSetMapSplitMode,
67
    onToggleMapControl,
68
    actionIcons = defaultActionIcons,
69
    mapControls,
28✔
70
    readOnly,
28✔
71
    mapState
72
  }) => {
73
    const splitMap = mapControls?.splitMap || ({} as MapControlItem);
74
    const menuRef = useRef<HTMLDivElement>(null);
75

76
    // The split-mode menu open state is normally kept in redux
77
    // (mapControls.splitMap.active) so it participates in the "only one
28✔
78
    // map-control menu open at a time" logic in toggleMapControlUpdater.
28✔
79
    // If a consumer supplies onSetMapSplitMode without onToggleMapControl, we
80
    // fall back to local component state so the menu still opens/closes.
28✔
81
    const useReduxMenuState = Boolean(onToggleMapControl);
1!
82
    const [localMenuOpen, setLocalMenuOpen] = useState(false);
1✔
83

NEW
84
    const toggleMenu = useCallback(() => {
×
85
      if (useReduxMenuState) {
86
        onToggleMapControl?.('splitMap');
87
      } else {
88
        setLocalMenuOpen(prev => !prev);
28✔
NEW
89
      }
×
NEW
90
    }, [useReduxMenuState, onToggleMapControl]);
×
NEW
91

×
92
    const closeMenu = useCallback(() => {
93
      if (useReduxMenuState) {
NEW
94
        if (splitMap.active) {
×
95
          onToggleMapControl?.('splitMap');
96
        }
97
      } else {
98
        setLocalMenuOpen(false);
99
      }
28!
100
    }, [useReduxMenuState, onToggleMapControl, splitMap.active]);
101

28✔
102
    const menuOpen =
103
      Boolean(onSetMapSplitMode) && (useReduxMenuState ? Boolean(splitMap.active) : localMenuOpen);
28✔
104

105
    const currentMode = mapState?.mapSplitMode || MapSplitMode.SINGLE_MAP;
1✔
106

1!
107
    const onClick = useCallback(
1✔
108
      event => {
UNCOV
109
        event.preventDefault();
×
110
        if (onSetMapSplitMode) {
111
          toggleMenu();
112
        } else {
113
          onToggleSplitMap(isSplit ? mapIndex : undefined);
114
        }
115
      },
28✔
116
      [isSplit, mapIndex, onToggleSplitMap, onSetMapSplitMode, toggleMenu]
UNCOV
117
    );
×
118

×
119
    const handleModeSelect = useCallback(
UNCOV
120
      (mode: string) => {
×
121
        if (onSetMapSplitMode) {
122
          onSetMapSplitMode({mapSplitMode: mode as MapSplitMode});
123
        }
124
        closeMenu();
125
      },
28✔
126
      [onSetMapSplitMode, closeMenu]
25✔
UNCOV
127
    );
×
UNCOV
128

×
129
    useEffect(() => {
130
      const handleClickOutside = (event: MouseEvent) => {
131
        if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
25!
NEW
132
          closeMenu();
×
133
        }
134
      };
25✔
135
      if (menuOpen) {
1✔
136
        document.addEventListener('mousedown', handleClickOutside);
137
      }
138
      return () => {
139
        document.removeEventListener('mousedown', handleClickOutside);
28✔
140
      };
141
    }, [menuOpen, closeMenu]);
28✔
142

5✔
143
    const isVisible = useMemo(() => splitMap.show && readOnly !== true, [splitMap.show, readOnly]);
144

23✔
145
    if (!splitMap.show) {
146
      return null;
44!
147
    }
148
    return isVisible ? (
NEW
149
      <div className="split-map-controls" style={{position: 'relative'}} ref={menuRef}>
×
150
        {menuOpen && onSetMapSplitMode ? (
NEW
151
          <MapControlToolbar $show={menuOpen}>
×
152
            {SPLIT_MODE_OPTIONS.map(option => (
153
              <ToolbarItem
154
                key={option.id}
155
                onClick={() => handleModeSelect(option.id)}
156
                label={option.label}
157
                active={currentMode === option.id}
158
              />
159
            ))}
160
          </MapControlToolbar>
161
        ) : null}
22✔
162
        <MapControlTooltip
163
          id="action-toggle"
1!
164
          message={
165
            onSetMapSplitMode
166
              ? 'tooltip.selectSplitMode'
167
              : isSplit
168
              ? 'tooltip.closePanel'
169
              : 'tooltip.switchToDualView'
170
          }
171
        >
172
          <MapControlButton
173
            active={isSplit}
22!
174
            onClick={onClick}
175
            className={classnames('map-control-button', 'split-map', {'close-map': isSplit})}
22✔
176
          >
177
            {currentMode === MapSplitMode.SWIPE_COMPARE ? (
178
              <SwipeCompareIcon height="18px" />
179
            ) : isSplit ? (
180
              <actionIcons.delete height="18px" />
181
            ) : (
182
              <actionIcons.split height="18px" />
183
            )}
184
          </MapControlButton>
185
        </MapControlTooltip>
186
      </div>
14✔
187
    ) : null;
14✔
188
  };
189

190
  SplitMapButton.displayName = 'SplitMapButton';
191
  return React.memo(SplitMapButton);
192
}
193

194
export default SplitMapButtonFactory;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc