@trackunit/react-map 0.2.171 → 0.2.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -40,7 +40,9 @@ var defaultTranslations = {
40
40
  "settings.mapStyle.roadmap": "Road map",
41
41
  "settings.mapStyle.roads": "Roads",
42
42
  "settings.mapStyle.satellite": "Satellite",
43
- "settings.mapStyle.title": "Map style"
43
+ "settings.mapStyle.title": "Map style",
44
+ "settings.mapStyle.traffic": "Traffic",
45
+ "settings.mapStyle.trafficRequiresRoads": "Turn on Roads to show traffic"
44
46
  };
45
47
 
46
48
  /** The translation namespace for this library */
@@ -1370,6 +1372,8 @@ const useMap = (adapterConfig) => {
1370
1372
  setMapType: adapter.setMapType.bind(adapter),
1371
1373
  setTheme: adapter.setTheme.bind(adapter),
1372
1374
  setShowRoads: adapter.setShowRoads.bind(adapter),
1375
+ // Optional capability: stays undefined for adapters without traffic support
1376
+ setShowTraffic: adapter.setShowTraffic?.bind(adapter),
1373
1377
  }), [adapter]);
1374
1378
  // Stable event subscription callback
1375
1379
  const on = react.useMemo(() => adapter.on.bind(adapter), [adapter]);
@@ -1846,9 +1850,12 @@ const BooleanButton = ({ config, overlaySide = undefined, resolved }) => {
1846
1850
  if (icon === undefined) {
1847
1851
  return null;
1848
1852
  }
1849
- const tooltipLabel = t("controls.boolean.tooltip", { label });
1853
+ // A disabled control's tooltip explains what unblocks it rather than what clicking would do
1854
+ const tooltipLabel = config.disabled === true && config.disabledReason !== undefined
1855
+ ? config.disabledReason
1856
+ : t("controls.boolean.tooltip", { label });
1850
1857
  const useRightOverlay = overlaySide === "right";
1851
- return (jsxRuntime.jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, children: jsxRuntime.jsx(reactComponents.IconButton, { ...(useRightOverlay ? { ariaLabel: label } : { title: tooltipLabel }), "aria-pressed": value, className: className, "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }));
1858
+ return (jsxRuntime.jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, children: jsxRuntime.jsx(reactComponents.IconButton, { ...(useRightOverlay ? { ariaLabel: label } : { title: tooltipLabel }), "aria-pressed": value, className: className, "data-testid": dataTestId, disabled: config.disabled ?? false, icon: jsxRuntime.jsx(reactComponents.Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }));
1852
1859
  };
1853
1860
 
1854
1861
  /**
@@ -2090,6 +2097,13 @@ const usePopoverPlacement = (triggerRef, containerRef, options = {}) => {
2090
2097
  return placement;
2091
2098
  };
2092
2099
 
2100
+ const cvaMenuBooleanItemLabel = cssClassVarianceUtilities.cva({
2101
+ base: ["flex", "items-center", "gap-2", "text-sm", "font-medium"],
2102
+ variants: {
2103
+ disabled: { true: "text-neutral-400", false: "text-neutral-700" },
2104
+ },
2105
+ defaultVariants: { disabled: false },
2106
+ });
2093
2107
  /**
2094
2108
  * Shared menu layout for boolean controls (toggle and checkbox).
2095
2109
  * Renders: [label] [icon] ...space... [children]
@@ -2097,22 +2111,30 @@ const usePopoverPlacement = (triggerRef, containerRef, options = {}) => {
2097
2111
  * The caller provides the specific input control (ToggleSwitch or Checkbox)
2098
2112
  * as children.
2099
2113
  *
2114
+ * The disabled reason renders as a tooltip on the row itself, not on the
2115
+ * input: the full-width dimmed row is what users hover, and a natively
2116
+ * disabled input (checkbox case) suppresses its own hover events anyway.
2117
+ *
2100
2118
  * @internal
2101
2119
  */
2102
- const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, icon = undefined, label, }) => (jsxRuntime.jsxs("label", { className: cvaControlMenuItemBase({
2103
- className: [
2104
- "flex",
2105
- "cursor-pointer",
2106
- "items-center",
2107
- "justify-between",
2108
- "gap-3",
2109
- "hover:bg-neutral-50",
2110
- "active:bg-neutral-100",
2111
- "transition-colors",
2112
- "duration-150",
2113
- className,
2114
- ],
2115
- }), "data-testid": dataTestId, children: [jsxRuntime.jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-neutral-700", children: [label, icon !== undefined ? jsxRuntime.jsx(reactComponents.Icon, { className: "text-neutral-500", name: icon, size: "small" }) : null] }), children] }));
2120
+ const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, disabled = false, disabledReason = undefined, disabledReasonId = undefined, icon = undefined, label, }) => {
2121
+ const row = (jsxRuntime.jsxs("label", { className: cvaControlMenuItemBase({
2122
+ className: [
2123
+ "flex",
2124
+ "items-center",
2125
+ "justify-between",
2126
+ "gap-3",
2127
+ "transition-colors",
2128
+ "duration-150",
2129
+ ...(disabled ? ["cursor-not-allowed"] : ["cursor-pointer", "hover:bg-neutral-50", "active:bg-neutral-100"]),
2130
+ className,
2131
+ ],
2132
+ }), "data-testid": dataTestId, children: [jsxRuntime.jsxs("span", { className: cvaMenuBooleanItemLabel({ disabled }), children: [label, icon !== undefined ? jsxRuntime.jsx(reactComponents.Icon, { className: "text-neutral-500", name: icon, size: "small" }) : null] }), disabled && disabledReason !== undefined ? (jsxRuntime.jsx("span", { className: "sr-only", id: disabledReasonId, children: disabledReason })) : null, children] }));
2133
+ if (disabled && disabledReason !== undefined) {
2134
+ return jsxRuntime.jsx(reactComponents.Tooltip, { label: disabledReason, children: row });
2135
+ }
2136
+ return row;
2137
+ };
2116
2138
 
2117
2139
  /**
2118
2140
  * Renders a checkbox control within a menu context.
@@ -2120,7 +2142,13 @@ const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, icon
2120
2142
  *
2121
2143
  * @internal
2122
2144
  */
2123
- const MenuCheckbox = ({ config, resolved }) => (jsxRuntime.jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], icon: resolved.icon, label: config.label, children: jsxRuntime.jsx(reactFormComponents.Checkbox, { checked: config.value, onChange: e => config.onChange(e.target.checked) }) }));
2145
+ const MenuCheckbox = ({ config, resolved }) => {
2146
+ // See MenuToggle: the row tooltip is hover-only, so the input also points
2147
+ // aria-describedby at a visually hidden copy of the reason.
2148
+ const disabledReasonId = react.useId();
2149
+ const isDisabledWithReason = config.disabled === true && config.disabledReason !== undefined;
2150
+ return (jsxRuntime.jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], disabled: config.disabled, disabledReason: config.disabledReason, disabledReasonId: disabledReasonId, icon: resolved.icon, label: config.label, children: jsxRuntime.jsx(reactFormComponents.Checkbox, { "aria-describedby": isDisabledWithReason ? disabledReasonId : undefined, checked: config.value, disabled: config.disabled ?? false, onChange: e => config.onChange(e.target.checked) }) }));
2151
+ };
2124
2152
 
2125
2153
  /**
2126
2154
  * Renders a toggle control within a menu context.
@@ -2128,7 +2156,18 @@ const MenuCheckbox = ({ config, resolved }) => (jsxRuntime.jsx(MenuBooleanItem,
2128
2156
  *
2129
2157
  * @internal
2130
2158
  */
2131
- const MenuToggle = ({ config, resolved }) => (jsxRuntime.jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], icon: resolved.icon, label: config.label, children: jsxRuntime.jsx(reactFormComponents.ToggleSwitch, { onChange: toggled => config.onChange(toggled), showInputFocus: false, size: "small", tabIndex: -1, toggled: config.value }) }));
2159
+ const MenuToggle = ({ config, resolved }) => {
2160
+ // The reason must also reach non-mouse users: the row tooltip is hover-only,
2161
+ // so the input additionally points aria-describedby at a visually hidden copy.
2162
+ const disabledReasonId = react.useId();
2163
+ const isDisabledWithReason = config.disabled === true && config.disabledReason !== undefined;
2164
+ return (jsxRuntime.jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], disabled: config.disabled, disabledReason: config.disabledReason, disabledReasonId: disabledReasonId, icon: resolved.icon, label: config.label, children: jsxRuntime.jsx(reactFormComponents.ToggleSwitch, { "aria-describedby": isDisabledWithReason ? disabledReasonId : undefined,
2165
+ // readOnly, not disabled: same dimmed visual and blocked interaction, but the
2166
+ // input stays focusable and is announced as dimmed instead of losing
2167
+ // focusability and being skipped in some AT browse modes (see the `disabled`
2168
+ // JSDoc on BooleanControlFields for the toggle/checkbox split).
2169
+ onChange: toggled => config.onChange(toggled), readOnly: config.disabled ?? false, showInputFocus: false, size: "small", tabIndex: -1, toggled: config.value }) }));
2170
+ };
2132
2171
 
2133
2172
  /**
2134
2173
  * Renders a button control within a menu context.
@@ -2775,19 +2814,26 @@ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme
2775
2814
  // Version
2776
2815
  // ============================================================================
2777
2816
  /**
2778
- * Increment this number to invalidate stored appearance settings in localStorage
2779
- * and reset all users to defaults.
2817
+ * Increment this number when the persisted appearance shape changes, and add a
2818
+ * matching migration step in useMapAppearanceControls so stored settings are
2819
+ * upgraded instead of reset. (v2: added showTraffic.)
2780
2820
  */
2781
- const APPEARANCE_SETTINGS_VERSION = 1;
2821
+ const APPEARANCE_SETTINGS_VERSION = 2;
2782
2822
  // ============================================================================
2783
2823
  // Persisted Appearance Schema (extends core mapAppearanceSchema with version)
2784
2824
  // ============================================================================
2785
2825
  const persistedAppearanceSchema = reactMapAdapterShared.mapAppearanceSchema.extend({
2826
+ // Required here (unlike the core schema, where it is optional for external
2827
+ // compatibility): the v2 migration guarantees stored data carries the key,
2828
+ // and useLocalStorageReducer needs an input = output schema.
2829
+ showTraffic: zod.z.boolean(),
2786
2830
  version: zod.z.literal(APPEARANCE_SETTINGS_VERSION),
2787
2831
  });
2788
2832
  /** Default persisted appearance when nothing is stored */
2789
2833
  const DEFAULT_PERSISTED_APPEARANCE = {
2790
2834
  ...reactMapAdapterShared.DEFAULT_MAP_APPEARANCE,
2835
+ // The core field is optional (absent = false); persisted state requires it
2836
+ showTraffic: reactMapAdapterShared.DEFAULT_MAP_APPEARANCE.showTraffic ?? false,
2791
2837
  version: APPEARANCE_SETTINGS_VERSION,
2792
2838
  };
2793
2839
  /** Reducer for persisted appearance state */
@@ -2799,6 +2845,8 @@ const appearanceReducer = (state, action) => {
2799
2845
  return { ...state, mapType: action.payload };
2800
2846
  case "setShowRoads":
2801
2847
  return { ...state, showRoads: action.payload };
2848
+ case "setShowTraffic":
2849
+ return { ...state, showTraffic: action.payload };
2802
2850
  default: {
2803
2851
  throw new Error(`${action} is not known`);
2804
2852
  }
@@ -2807,12 +2855,39 @@ const appearanceReducer = (state, action) => {
2807
2855
 
2808
2856
  const DEFAULT_THEMES = ["light", "dark"];
2809
2857
  const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2810
- const APPEARANCE_STORAGE_VERSION = 1;
2858
+ /**
2859
+ * The v1 persisted shape, frozen as a literal. Deliberately NOT derived from
2860
+ * the live mapAppearanceSchema: deriving would make every future appearance
2861
+ * field retroactively required on v1-era data, failing the parse below and
2862
+ * resetting exactly the users the migration exists to carry over.
2863
+ */
2864
+ const appearanceV1Schema = zod.z.object({
2865
+ theme: zod.z.enum(["light", "dark"]),
2866
+ mapType: zod.z.enum(["roadmap", "satellite", "hybrid"]),
2867
+ showRoads: zod.z.boolean(),
2868
+ version: zod.z.literal(1),
2869
+ });
2811
2870
  const appearanceMigrationV1 = {
2812
- version: APPEARANCE_STORAGE_VERSION,
2871
+ version: 1,
2872
+ migrate: data => {
2873
+ const result = appearanceV1Schema.safeParse(data);
2874
+ // Pass unrecognized data through: it may already be a newer shape
2875
+ // (stored without a version envelope), which the next step recognizes.
2876
+ return result.success ? result.data : data;
2877
+ },
2878
+ };
2879
+ /** v2 added the showTraffic preference; carry v1 settings over instead of resetting them */
2880
+ const appearanceMigrationV2 = {
2881
+ version: APPEARANCE_SETTINGS_VERSION,
2813
2882
  migrate: data => {
2814
- const result = persistedAppearanceSchema.safeParse(data);
2815
- return result.success ? result.data : DEFAULT_PERSISTED_APPEARANCE;
2883
+ const current = persistedAppearanceSchema.safeParse(data);
2884
+ if (current.success) {
2885
+ return current.data;
2886
+ }
2887
+ const v1 = appearanceV1Schema.safeParse(data);
2888
+ return v1.success
2889
+ ? { ...v1.data, showTraffic: false, version: APPEARANCE_SETTINGS_VERSION }
2890
+ : DEFAULT_PERSISTED_APPEARANCE;
2816
2891
  },
2817
2892
  };
2818
2893
  const themeToTranslationKey = (theme) => {
@@ -2886,6 +2961,8 @@ const buildAppearanceOptions = (themes, mapTypes, t) => {
2886
2961
  * Returns an array with a single `MenuControlConfig` that contains:
2887
2962
  * - A custom item with a grid of live tiny map previews
2888
2963
  * - A toggle item for roads overlay (when satellite/hybrid is selected)
2964
+ * - A toggle item for live traffic (when the adapter supports it; disabled on
2965
+ * satellite while roads are off, since traffic only renders on hybrid)
2889
2966
  *
2890
2967
  * @example
2891
2968
  * ```tsx
@@ -2906,19 +2983,35 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2906
2983
  const themes = appearanceOptions?.themes ?? DEFAULT_THEMES;
2907
2984
  const mapTypes = appearanceOptions?.mapTypes ?? DEFAULT_MAP_TYPES;
2908
2985
  const showRoadsToggle = appearanceOptions?.roads !== false;
2986
+ // Traffic is an optional adapter capability: no setShowTraffic action means no toggle
2987
+ const setShowTraffic = api.actions.setShowTraffic;
2988
+ // `traffic: false` opts the whole surface out of traffic, not just out of the toggle:
2989
+ // with a shared persistence key another surface may have stored showTraffic: true,
2990
+ // and rendering traffic here with no toggle to turn it off would trap the user.
2991
+ const trafficOptedOut = appearanceOptions?.traffic === false;
2992
+ const showTrafficToggle = !trafficOptedOut && setShowTraffic !== undefined;
2909
2993
  const [localState, dispatch] = reactComponents.useLocalStorageReducer({
2910
2994
  key: persistenceKey,
2911
2995
  defaultState: DEFAULT_PERSISTED_APPEARANCE,
2912
- migration: { version: APPEARANCE_STORAGE_VERSION, steps: [appearanceMigrationV1] },
2996
+ migration: { version: APPEARANCE_SETTINGS_VERSION, steps: [appearanceMigrationV1, appearanceMigrationV2] },
2913
2997
  reducer: appearanceReducer,
2914
2998
  schema: persistedAppearanceSchema,
2915
2999
  });
2916
- // Push localStorage state to the adapter when it changes
3000
+ // Push localStorage state to the adapter when it changes. Traffic is pushed as the
3001
+ // raw preference: the adapter itself suppresses rendering where the provider cannot
3002
+ // draw traffic (plain satellite, low zoom), so the preference survives roads/mapType
3003
+ // changes and is restored when they change back.
2917
3004
  reactComponents.useWatch({
2918
- value: { mapType: localState.mapType, showRoads: localState.showRoads, theme: localState.theme },
2919
- onChange: ({ mapType, showRoads, theme }) => {
3005
+ value: {
3006
+ mapType: localState.mapType,
3007
+ showRoads: localState.showRoads,
3008
+ showTraffic: trafficOptedOut ? false : localState.showTraffic,
3009
+ theme: localState.theme,
3010
+ },
3011
+ onChange: ({ mapType, showRoads, showTraffic, theme }) => {
2920
3012
  void api.actions.setMapType(mapType);
2921
3013
  void api.actions.setShowRoads(showRoads);
3014
+ void setShowTraffic?.(showTraffic);
2922
3015
  void api.actions.setTheme(theme);
2923
3016
  },
2924
3017
  immediate: true,
@@ -2939,6 +3032,14 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2939
3032
  if (appearance.showRoads !== localState.showRoads) {
2940
3033
  dispatch({ type: "setShowRoads", payload: appearance.showRoads });
2941
3034
  }
3035
+ // Only sync traffic back where the toggle is live. An unsupported adapter (and
3036
+ // an opted-out surface) reports showTraffic: false forever, and syncing that
3037
+ // back would wipe a preference stored by another map surface sharing the same
3038
+ // persistence key.
3039
+ const adapterShowTraffic = appearance.showTraffic ?? false;
3040
+ if (showTrafficToggle && adapterShowTraffic !== localState.showTraffic) {
3041
+ dispatch({ type: "setShowTraffic", payload: adapterShowTraffic });
3042
+ }
2942
3043
  },
2943
3044
  skip: !api.state.isReady,
2944
3045
  });
@@ -2949,6 +3050,9 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2949
3050
  const handleRoadsChange = react.useCallback((value) => {
2950
3051
  dispatch({ type: "setShowRoads", payload: value });
2951
3052
  }, [dispatch]);
3053
+ const handleTrafficChange = react.useCallback((value) => {
3054
+ dispatch({ type: "setShowTraffic", payload: value });
3055
+ }, [dispatch]);
2952
3056
  const previewOptions = react.useMemo(() => buildAppearanceOptions(themes, mapTypes, t), [themes, mapTypes, t]);
2953
3057
  const adapterConfig = api.adapterConfig;
2954
3058
  return react.useMemo(() => {
@@ -2967,8 +3071,12 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2967
3071
  },
2968
3072
  ];
2969
3073
  const isSatelliteOrHybrid = localState.mapType === "satellite" || localState.mapType === "hybrid";
2970
- if (showRoadsToggle && isSatelliteOrHybrid) {
2971
- menuItems.push({ type: "separator" }, {
3074
+ const includeRoadsToggle = showRoadsToggle && isSatelliteOrHybrid;
3075
+ if (includeRoadsToggle || showTrafficToggle) {
3076
+ menuItems.push({ type: "separator" });
3077
+ }
3078
+ if (includeRoadsToggle) {
3079
+ menuItems.push({
2972
3080
  type: "toggle",
2973
3081
  id: "roads-overlay",
2974
3082
  label: t("settings.mapStyle.roads"),
@@ -2977,6 +3085,25 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2977
3085
  "data-testid": "map-style-roads-toggle",
2978
3086
  });
2979
3087
  }
3088
+ if (showTrafficToggle) {
3089
+ // On satellite, traffic can only render on top of the roads overlay (Google
3090
+ // renders no traffic on plain SATELLITE); the toggle is disabled while roads
3091
+ // are off but keeps DISPLAYING the persisted preference — the setting is
3092
+ // still "on", it just cannot apply until roads return, and the tooltip says
3093
+ // so. Explicit "hybrid" always renders roads (and traffic), so the gate
3094
+ // applies to "satellite" only.
3095
+ const trafficBlockedByRoads = localState.mapType === "satellite" && !localState.showRoads;
3096
+ menuItems.push({
3097
+ type: "toggle",
3098
+ id: "traffic-overlay",
3099
+ label: t("settings.mapStyle.traffic"),
3100
+ value: localState.showTraffic,
3101
+ disabled: trafficBlockedByRoads,
3102
+ disabledReason: t("settings.mapStyle.trafficRequiresRoads"),
3103
+ onChange: handleTrafficChange,
3104
+ "data-testid": "map-style-traffic-toggle",
3105
+ });
3106
+ }
2980
3107
  return [
2981
3108
  {
2982
3109
  type: "menu",
@@ -2993,10 +3120,13 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2993
3120
  localState.mapType,
2994
3121
  localState.theme,
2995
3122
  localState.showRoads,
3123
+ localState.showTraffic,
2996
3124
  handleAppearanceChange,
2997
3125
  handleRoadsChange,
3126
+ handleTrafficChange,
2998
3127
  previewOptions,
2999
3128
  showRoadsToggle,
3129
+ showTrafficToggle,
3000
3130
  ]);
3001
3131
  };
3002
3132
 
@@ -12586,7 +12716,7 @@ const INITIAL_STATE = {
12586
12716
  isIdle: true,
12587
12717
  isReady: true,
12588
12718
  initializationFailed: false,
12589
- appearance: { theme: "light", mapType: "roadmap", showRoads: false },
12719
+ appearance: { theme: "light", mapType: "roadmap", showRoads: false, showTraffic: false },
12590
12720
  tileSize: 256,
12591
12721
  };
12592
12722
  const noop = () => undefined;
@@ -12635,6 +12765,7 @@ const mockMapApi = (overrides) => {
12635
12765
  setMapType: resolvedVoid,
12636
12766
  setTheme: resolvedVoid,
12637
12767
  setShowRoads: resolvedVoid,
12768
+ setShowTraffic: resolvedVoid,
12638
12769
  ...(overrides?.actions ?? {}),
12639
12770
  };
12640
12771
  const adapterConfig = {
@@ -12661,6 +12792,7 @@ const mockMapApi = (overrides) => {
12661
12792
  setMapType: resolvedVoid,
12662
12793
  setTheme: resolvedVoid,
12663
12794
  setShowRoads: resolvedVoid,
12795
+ setShowTraffic: resolvedVoid,
12664
12796
  on: () => noopUnsubscribe,
12665
12797
  notifyInitializationFailed: noop,
12666
12798
  destroy: noop,
package/index.esm.js CHANGED
@@ -39,7 +39,9 @@ var defaultTranslations = {
39
39
  "settings.mapStyle.roadmap": "Road map",
40
40
  "settings.mapStyle.roads": "Roads",
41
41
  "settings.mapStyle.satellite": "Satellite",
42
- "settings.mapStyle.title": "Map style"
42
+ "settings.mapStyle.title": "Map style",
43
+ "settings.mapStyle.traffic": "Traffic",
44
+ "settings.mapStyle.trafficRequiresRoads": "Turn on Roads to show traffic"
43
45
  };
44
46
 
45
47
  /** The translation namespace for this library */
@@ -1369,6 +1371,8 @@ const useMap = (adapterConfig) => {
1369
1371
  setMapType: adapter.setMapType.bind(adapter),
1370
1372
  setTheme: adapter.setTheme.bind(adapter),
1371
1373
  setShowRoads: adapter.setShowRoads.bind(adapter),
1374
+ // Optional capability: stays undefined for adapters without traffic support
1375
+ setShowTraffic: adapter.setShowTraffic?.bind(adapter),
1372
1376
  }), [adapter]);
1373
1377
  // Stable event subscription callback
1374
1378
  const on = useMemo(() => adapter.on.bind(adapter), [adapter]);
@@ -1845,9 +1849,12 @@ const BooleanButton = ({ config, overlaySide = undefined, resolved }) => {
1845
1849
  if (icon === undefined) {
1846
1850
  return null;
1847
1851
  }
1848
- const tooltipLabel = t("controls.boolean.tooltip", { label });
1852
+ // A disabled control's tooltip explains what unblocks it rather than what clicking would do
1853
+ const tooltipLabel = config.disabled === true && config.disabledReason !== undefined
1854
+ ? config.disabledReason
1855
+ : t("controls.boolean.tooltip", { label });
1849
1856
  const useRightOverlay = overlaySide === "right";
1850
- return (jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, children: jsx(IconButton, { ...(useRightOverlay ? { ariaLabel: label } : { title: tooltipLabel }), "aria-pressed": value, className: className, "data-testid": dataTestId, icon: jsx(Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }));
1857
+ return (jsx(StandaloneControlTooltip, { label: tooltipLabel, overlaySide: overlaySide, children: jsx(IconButton, { ...(useRightOverlay ? { ariaLabel: label } : { title: tooltipLabel }), "aria-pressed": value, className: className, "data-testid": dataTestId, disabled: config.disabled ?? false, icon: jsx(Icon, { name: icon, size: "small" }), onClick: () => onClickChange(!value), size: size, variant: value ? "primary" : "secondary" }) }));
1851
1858
  };
1852
1859
 
1853
1860
  /**
@@ -2089,6 +2096,13 @@ const usePopoverPlacement = (triggerRef, containerRef, options = {}) => {
2089
2096
  return placement;
2090
2097
  };
2091
2098
 
2099
+ const cvaMenuBooleanItemLabel = cva({
2100
+ base: ["flex", "items-center", "gap-2", "text-sm", "font-medium"],
2101
+ variants: {
2102
+ disabled: { true: "text-neutral-400", false: "text-neutral-700" },
2103
+ },
2104
+ defaultVariants: { disabled: false },
2105
+ });
2092
2106
  /**
2093
2107
  * Shared menu layout for boolean controls (toggle and checkbox).
2094
2108
  * Renders: [label] [icon] ...space... [children]
@@ -2096,22 +2110,30 @@ const usePopoverPlacement = (triggerRef, containerRef, options = {}) => {
2096
2110
  * The caller provides the specific input control (ToggleSwitch or Checkbox)
2097
2111
  * as children.
2098
2112
  *
2113
+ * The disabled reason renders as a tooltip on the row itself, not on the
2114
+ * input: the full-width dimmed row is what users hover, and a natively
2115
+ * disabled input (checkbox case) suppresses its own hover events anyway.
2116
+ *
2099
2117
  * @internal
2100
2118
  */
2101
- const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, icon = undefined, label, }) => (jsxs("label", { className: cvaControlMenuItemBase({
2102
- className: [
2103
- "flex",
2104
- "cursor-pointer",
2105
- "items-center",
2106
- "justify-between",
2107
- "gap-3",
2108
- "hover:bg-neutral-50",
2109
- "active:bg-neutral-100",
2110
- "transition-colors",
2111
- "duration-150",
2112
- className,
2113
- ],
2114
- }), "data-testid": dataTestId, children: [jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-neutral-700", children: [label, icon !== undefined ? jsx(Icon, { className: "text-neutral-500", name: icon, size: "small" }) : null] }), children] }));
2119
+ const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, disabled = false, disabledReason = undefined, disabledReasonId = undefined, icon = undefined, label, }) => {
2120
+ const row = (jsxs("label", { className: cvaControlMenuItemBase({
2121
+ className: [
2122
+ "flex",
2123
+ "items-center",
2124
+ "justify-between",
2125
+ "gap-3",
2126
+ "transition-colors",
2127
+ "duration-150",
2128
+ ...(disabled ? ["cursor-not-allowed"] : ["cursor-pointer", "hover:bg-neutral-50", "active:bg-neutral-100"]),
2129
+ className,
2130
+ ],
2131
+ }), "data-testid": dataTestId, children: [jsxs("span", { className: cvaMenuBooleanItemLabel({ disabled }), children: [label, icon !== undefined ? jsx(Icon, { className: "text-neutral-500", name: icon, size: "small" }) : null] }), disabled && disabledReason !== undefined ? (jsx("span", { className: "sr-only", id: disabledReasonId, children: disabledReason })) : null, children] }));
2132
+ if (disabled && disabledReason !== undefined) {
2133
+ return jsx(Tooltip, { label: disabledReason, children: row });
2134
+ }
2135
+ return row;
2136
+ };
2115
2137
 
2116
2138
  /**
2117
2139
  * Renders a checkbox control within a menu context.
@@ -2119,7 +2141,13 @@ const MenuBooleanItem = ({ children, className, "data-testid": dataTestId, icon
2119
2141
  *
2120
2142
  * @internal
2121
2143
  */
2122
- const MenuCheckbox = ({ config, resolved }) => (jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], icon: resolved.icon, label: config.label, children: jsx(Checkbox, { checked: config.value, onChange: e => config.onChange(e.target.checked) }) }));
2144
+ const MenuCheckbox = ({ config, resolved }) => {
2145
+ // See MenuToggle: the row tooltip is hover-only, so the input also points
2146
+ // aria-describedby at a visually hidden copy of the reason.
2147
+ const disabledReasonId = useId();
2148
+ const isDisabledWithReason = config.disabled === true && config.disabledReason !== undefined;
2149
+ return (jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], disabled: config.disabled, disabledReason: config.disabledReason, disabledReasonId: disabledReasonId, icon: resolved.icon, label: config.label, children: jsx(Checkbox, { "aria-describedby": isDisabledWithReason ? disabledReasonId : undefined, checked: config.value, disabled: config.disabled ?? false, onChange: e => config.onChange(e.target.checked) }) }));
2150
+ };
2123
2151
 
2124
2152
  /**
2125
2153
  * Renders a toggle control within a menu context.
@@ -2127,7 +2155,18 @@ const MenuCheckbox = ({ config, resolved }) => (jsx(MenuBooleanItem, { className
2127
2155
  *
2128
2156
  * @internal
2129
2157
  */
2130
- const MenuToggle = ({ config, resolved }) => (jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], icon: resolved.icon, label: config.label, children: jsx(ToggleSwitch, { onChange: toggled => config.onChange(toggled), showInputFocus: false, size: "small", tabIndex: -1, toggled: config.value }) }));
2158
+ const MenuToggle = ({ config, resolved }) => {
2159
+ // The reason must also reach non-mouse users: the row tooltip is hover-only,
2160
+ // so the input additionally points aria-describedby at a visually hidden copy.
2161
+ const disabledReasonId = useId();
2162
+ const isDisabledWithReason = config.disabled === true && config.disabledReason !== undefined;
2163
+ return (jsx(MenuBooleanItem, { className: config.className, "data-testid": config["data-testid"], disabled: config.disabled, disabledReason: config.disabledReason, disabledReasonId: disabledReasonId, icon: resolved.icon, label: config.label, children: jsx(ToggleSwitch, { "aria-describedby": isDisabledWithReason ? disabledReasonId : undefined,
2164
+ // readOnly, not disabled: same dimmed visual and blocked interaction, but the
2165
+ // input stays focusable and is announced as dimmed instead of losing
2166
+ // focusability and being skipped in some AT browse modes (see the `disabled`
2167
+ // JSDoc on BooleanControlFields for the toggle/checkbox split).
2168
+ onChange: toggled => config.onChange(toggled), readOnly: config.disabled ?? false, showInputFocus: false, size: "small", tabIndex: -1, toggled: config.value }) }));
2169
+ };
2131
2170
 
2132
2171
  /**
2133
2172
  * Renders a button control within a menu context.
@@ -2774,19 +2813,26 @@ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme
2774
2813
  // Version
2775
2814
  // ============================================================================
2776
2815
  /**
2777
- * Increment this number to invalidate stored appearance settings in localStorage
2778
- * and reset all users to defaults.
2816
+ * Increment this number when the persisted appearance shape changes, and add a
2817
+ * matching migration step in useMapAppearanceControls so stored settings are
2818
+ * upgraded instead of reset. (v2: added showTraffic.)
2779
2819
  */
2780
- const APPEARANCE_SETTINGS_VERSION = 1;
2820
+ const APPEARANCE_SETTINGS_VERSION = 2;
2781
2821
  // ============================================================================
2782
2822
  // Persisted Appearance Schema (extends core mapAppearanceSchema with version)
2783
2823
  // ============================================================================
2784
2824
  const persistedAppearanceSchema = mapAppearanceSchema.extend({
2825
+ // Required here (unlike the core schema, where it is optional for external
2826
+ // compatibility): the v2 migration guarantees stored data carries the key,
2827
+ // and useLocalStorageReducer needs an input = output schema.
2828
+ showTraffic: z.boolean(),
2785
2829
  version: z.literal(APPEARANCE_SETTINGS_VERSION),
2786
2830
  });
2787
2831
  /** Default persisted appearance when nothing is stored */
2788
2832
  const DEFAULT_PERSISTED_APPEARANCE = {
2789
2833
  ...DEFAULT_MAP_APPEARANCE,
2834
+ // The core field is optional (absent = false); persisted state requires it
2835
+ showTraffic: DEFAULT_MAP_APPEARANCE.showTraffic ?? false,
2790
2836
  version: APPEARANCE_SETTINGS_VERSION,
2791
2837
  };
2792
2838
  /** Reducer for persisted appearance state */
@@ -2798,6 +2844,8 @@ const appearanceReducer = (state, action) => {
2798
2844
  return { ...state, mapType: action.payload };
2799
2845
  case "setShowRoads":
2800
2846
  return { ...state, showRoads: action.payload };
2847
+ case "setShowTraffic":
2848
+ return { ...state, showTraffic: action.payload };
2801
2849
  default: {
2802
2850
  throw new Error(`${action} is not known`);
2803
2851
  }
@@ -2806,12 +2854,39 @@ const appearanceReducer = (state, action) => {
2806
2854
 
2807
2855
  const DEFAULT_THEMES = ["light", "dark"];
2808
2856
  const DEFAULT_MAP_TYPES = ["roadmap", "satellite"];
2809
- const APPEARANCE_STORAGE_VERSION = 1;
2857
+ /**
2858
+ * The v1 persisted shape, frozen as a literal. Deliberately NOT derived from
2859
+ * the live mapAppearanceSchema: deriving would make every future appearance
2860
+ * field retroactively required on v1-era data, failing the parse below and
2861
+ * resetting exactly the users the migration exists to carry over.
2862
+ */
2863
+ const appearanceV1Schema = z.object({
2864
+ theme: z.enum(["light", "dark"]),
2865
+ mapType: z.enum(["roadmap", "satellite", "hybrid"]),
2866
+ showRoads: z.boolean(),
2867
+ version: z.literal(1),
2868
+ });
2810
2869
  const appearanceMigrationV1 = {
2811
- version: APPEARANCE_STORAGE_VERSION,
2870
+ version: 1,
2871
+ migrate: data => {
2872
+ const result = appearanceV1Schema.safeParse(data);
2873
+ // Pass unrecognized data through: it may already be a newer shape
2874
+ // (stored without a version envelope), which the next step recognizes.
2875
+ return result.success ? result.data : data;
2876
+ },
2877
+ };
2878
+ /** v2 added the showTraffic preference; carry v1 settings over instead of resetting them */
2879
+ const appearanceMigrationV2 = {
2880
+ version: APPEARANCE_SETTINGS_VERSION,
2812
2881
  migrate: data => {
2813
- const result = persistedAppearanceSchema.safeParse(data);
2814
- return result.success ? result.data : DEFAULT_PERSISTED_APPEARANCE;
2882
+ const current = persistedAppearanceSchema.safeParse(data);
2883
+ if (current.success) {
2884
+ return current.data;
2885
+ }
2886
+ const v1 = appearanceV1Schema.safeParse(data);
2887
+ return v1.success
2888
+ ? { ...v1.data, showTraffic: false, version: APPEARANCE_SETTINGS_VERSION }
2889
+ : DEFAULT_PERSISTED_APPEARANCE;
2815
2890
  },
2816
2891
  };
2817
2892
  const themeToTranslationKey = (theme) => {
@@ -2885,6 +2960,8 @@ const buildAppearanceOptions = (themes, mapTypes, t) => {
2885
2960
  * Returns an array with a single `MenuControlConfig` that contains:
2886
2961
  * - A custom item with a grid of live tiny map previews
2887
2962
  * - A toggle item for roads overlay (when satellite/hybrid is selected)
2963
+ * - A toggle item for live traffic (when the adapter supports it; disabled on
2964
+ * satellite while roads are off, since traffic only renders on hybrid)
2888
2965
  *
2889
2966
  * @example
2890
2967
  * ```tsx
@@ -2905,19 +2982,35 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2905
2982
  const themes = appearanceOptions?.themes ?? DEFAULT_THEMES;
2906
2983
  const mapTypes = appearanceOptions?.mapTypes ?? DEFAULT_MAP_TYPES;
2907
2984
  const showRoadsToggle = appearanceOptions?.roads !== false;
2985
+ // Traffic is an optional adapter capability: no setShowTraffic action means no toggle
2986
+ const setShowTraffic = api.actions.setShowTraffic;
2987
+ // `traffic: false` opts the whole surface out of traffic, not just out of the toggle:
2988
+ // with a shared persistence key another surface may have stored showTraffic: true,
2989
+ // and rendering traffic here with no toggle to turn it off would trap the user.
2990
+ const trafficOptedOut = appearanceOptions?.traffic === false;
2991
+ const showTrafficToggle = !trafficOptedOut && setShowTraffic !== undefined;
2908
2992
  const [localState, dispatch] = useLocalStorageReducer({
2909
2993
  key: persistenceKey,
2910
2994
  defaultState: DEFAULT_PERSISTED_APPEARANCE,
2911
- migration: { version: APPEARANCE_STORAGE_VERSION, steps: [appearanceMigrationV1] },
2995
+ migration: { version: APPEARANCE_SETTINGS_VERSION, steps: [appearanceMigrationV1, appearanceMigrationV2] },
2912
2996
  reducer: appearanceReducer,
2913
2997
  schema: persistedAppearanceSchema,
2914
2998
  });
2915
- // Push localStorage state to the adapter when it changes
2999
+ // Push localStorage state to the adapter when it changes. Traffic is pushed as the
3000
+ // raw preference: the adapter itself suppresses rendering where the provider cannot
3001
+ // draw traffic (plain satellite, low zoom), so the preference survives roads/mapType
3002
+ // changes and is restored when they change back.
2916
3003
  useWatch({
2917
- value: { mapType: localState.mapType, showRoads: localState.showRoads, theme: localState.theme },
2918
- onChange: ({ mapType, showRoads, theme }) => {
3004
+ value: {
3005
+ mapType: localState.mapType,
3006
+ showRoads: localState.showRoads,
3007
+ showTraffic: trafficOptedOut ? false : localState.showTraffic,
3008
+ theme: localState.theme,
3009
+ },
3010
+ onChange: ({ mapType, showRoads, showTraffic, theme }) => {
2919
3011
  void api.actions.setMapType(mapType);
2920
3012
  void api.actions.setShowRoads(showRoads);
3013
+ void setShowTraffic?.(showTraffic);
2921
3014
  void api.actions.setTheme(theme);
2922
3015
  },
2923
3016
  immediate: true,
@@ -2938,6 +3031,14 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2938
3031
  if (appearance.showRoads !== localState.showRoads) {
2939
3032
  dispatch({ type: "setShowRoads", payload: appearance.showRoads });
2940
3033
  }
3034
+ // Only sync traffic back where the toggle is live. An unsupported adapter (and
3035
+ // an opted-out surface) reports showTraffic: false forever, and syncing that
3036
+ // back would wipe a preference stored by another map surface sharing the same
3037
+ // persistence key.
3038
+ const adapterShowTraffic = appearance.showTraffic ?? false;
3039
+ if (showTrafficToggle && adapterShowTraffic !== localState.showTraffic) {
3040
+ dispatch({ type: "setShowTraffic", payload: adapterShowTraffic });
3041
+ }
2941
3042
  },
2942
3043
  skip: !api.state.isReady,
2943
3044
  });
@@ -2948,6 +3049,9 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2948
3049
  const handleRoadsChange = useCallback((value) => {
2949
3050
  dispatch({ type: "setShowRoads", payload: value });
2950
3051
  }, [dispatch]);
3052
+ const handleTrafficChange = useCallback((value) => {
3053
+ dispatch({ type: "setShowTraffic", payload: value });
3054
+ }, [dispatch]);
2951
3055
  const previewOptions = useMemo(() => buildAppearanceOptions(themes, mapTypes, t), [themes, mapTypes, t]);
2952
3056
  const adapterConfig = api.adapterConfig;
2953
3057
  return useMemo(() => {
@@ -2966,8 +3070,12 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2966
3070
  },
2967
3071
  ];
2968
3072
  const isSatelliteOrHybrid = localState.mapType === "satellite" || localState.mapType === "hybrid";
2969
- if (showRoadsToggle && isSatelliteOrHybrid) {
2970
- menuItems.push({ type: "separator" }, {
3073
+ const includeRoadsToggle = showRoadsToggle && isSatelliteOrHybrid;
3074
+ if (includeRoadsToggle || showTrafficToggle) {
3075
+ menuItems.push({ type: "separator" });
3076
+ }
3077
+ if (includeRoadsToggle) {
3078
+ menuItems.push({
2971
3079
  type: "toggle",
2972
3080
  id: "roads-overlay",
2973
3081
  label: t("settings.mapStyle.roads"),
@@ -2976,6 +3084,25 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2976
3084
  "data-testid": "map-style-roads-toggle",
2977
3085
  });
2978
3086
  }
3087
+ if (showTrafficToggle) {
3088
+ // On satellite, traffic can only render on top of the roads overlay (Google
3089
+ // renders no traffic on plain SATELLITE); the toggle is disabled while roads
3090
+ // are off but keeps DISPLAYING the persisted preference — the setting is
3091
+ // still "on", it just cannot apply until roads return, and the tooltip says
3092
+ // so. Explicit "hybrid" always renders roads (and traffic), so the gate
3093
+ // applies to "satellite" only.
3094
+ const trafficBlockedByRoads = localState.mapType === "satellite" && !localState.showRoads;
3095
+ menuItems.push({
3096
+ type: "toggle",
3097
+ id: "traffic-overlay",
3098
+ label: t("settings.mapStyle.traffic"),
3099
+ value: localState.showTraffic,
3100
+ disabled: trafficBlockedByRoads,
3101
+ disabledReason: t("settings.mapStyle.trafficRequiresRoads"),
3102
+ onChange: handleTrafficChange,
3103
+ "data-testid": "map-style-traffic-toggle",
3104
+ });
3105
+ }
2979
3106
  return [
2980
3107
  {
2981
3108
  type: "menu",
@@ -2992,10 +3119,13 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2992
3119
  localState.mapType,
2993
3120
  localState.theme,
2994
3121
  localState.showRoads,
3122
+ localState.showTraffic,
2995
3123
  handleAppearanceChange,
2996
3124
  handleRoadsChange,
3125
+ handleTrafficChange,
2997
3126
  previewOptions,
2998
3127
  showRoadsToggle,
3128
+ showTrafficToggle,
2999
3129
  ]);
3000
3130
  };
3001
3131
 
@@ -12585,7 +12715,7 @@ const INITIAL_STATE = {
12585
12715
  isIdle: true,
12586
12716
  isReady: true,
12587
12717
  initializationFailed: false,
12588
- appearance: { theme: "light", mapType: "roadmap", showRoads: false },
12718
+ appearance: { theme: "light", mapType: "roadmap", showRoads: false, showTraffic: false },
12589
12719
  tileSize: 256,
12590
12720
  };
12591
12721
  const noop = () => undefined;
@@ -12634,6 +12764,7 @@ const mockMapApi = (overrides) => {
12634
12764
  setMapType: resolvedVoid,
12635
12765
  setTheme: resolvedVoid,
12636
12766
  setShowRoads: resolvedVoid,
12767
+ setShowTraffic: resolvedVoid,
12637
12768
  ...(overrides?.actions ?? {}),
12638
12769
  };
12639
12770
  const adapterConfig = {
@@ -12660,6 +12791,7 @@ const mockMapApi = (overrides) => {
12660
12791
  setMapType: resolvedVoid,
12661
12792
  setTheme: resolvedVoid,
12662
12793
  setShowRoads: resolvedVoid,
12794
+ setShowTraffic: resolvedVoid,
12663
12795
  on: () => noopUnsubscribe,
12664
12796
  notifyInitializationFailed: noop,
12665
12797
  destroy: noop,
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.171",
3
+ "version": "0.2.173",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "3.2.0",
11
- "@trackunit/css-class-variance-utilities": "2.0.19",
12
- "@trackunit/react-form-components": "2.7.11",
13
- "@trackunit/react-core-hooks": "1.22.4",
14
- "@trackunit/geo-json-utils": "1.15.52",
15
- "@trackunit/i18n-library-translation": "2.5.4",
10
+ "@trackunit/react-components": "3.2.2",
11
+ "@trackunit/css-class-variance-utilities": "2.0.21",
12
+ "@trackunit/react-form-components": "2.7.13",
13
+ "@trackunit/react-core-hooks": "1.22.6",
14
+ "@trackunit/geo-json-utils": "1.15.54",
15
+ "@trackunit/i18n-library-translation": "2.5.6",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.137",
18
- "@trackunit/react-map-color-utils": "0.0.112",
19
- "@trackunit/ui-design-tokens": "1.15.40",
17
+ "@trackunit/react-map-adapter-shared": "0.0.139",
18
+ "@trackunit/react-map-color-utils": "0.0.114",
19
+ "@trackunit/ui-design-tokens": "1.15.41",
20
20
  "@floating-ui/react": "^0.26.25",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "tailwind-merge": "^2.0.0",
@@ -2,25 +2,29 @@ import type { AdapterConfig } from "@trackunit/react-map-adapter-shared";
2
2
  import { z } from "zod";
3
3
  import { type MapActions, type MapStatus, type MapTheme, type MapType } from "../core/types";
4
4
  /**
5
- * Increment this number to invalidate stored appearance settings in localStorage
6
- * and reset all users to defaults.
5
+ * Increment this number when the persisted appearance shape changes, and add a
6
+ * matching migration step in useMapAppearanceControls so stored settings are
7
+ * upgraded instead of reset. (v2: added showTraffic.)
7
8
  */
8
- export declare const APPEARANCE_SETTINGS_VERSION = 1;
9
+ export declare const APPEARANCE_SETTINGS_VERSION = 2;
9
10
  export declare const persistedAppearanceSchema: z.ZodObject<{
10
11
  theme: z.ZodEnum<["light", "dark"]>;
11
12
  mapType: z.ZodEnum<["roadmap", "satellite", "hybrid"]>;
12
13
  showRoads: z.ZodBoolean;
13
14
  } & {
14
- version: z.ZodLiteral<1>;
15
+ showTraffic: z.ZodBoolean;
16
+ version: z.ZodLiteral<2>;
15
17
  }, "strip", z.ZodTypeAny, {
16
18
  mapType: "roadmap" | "satellite" | "hybrid";
17
19
  theme: "light" | "dark";
18
- version: 1;
20
+ showTraffic: boolean;
21
+ version: 2;
19
22
  showRoads: boolean;
20
23
  }, {
21
24
  mapType: "roadmap" | "satellite" | "hybrid";
22
25
  theme: "light" | "dark";
23
- version: 1;
26
+ showTraffic: boolean;
27
+ version: 2;
24
28
  showRoads: boolean;
25
29
  }>;
26
30
  /** Appearance state persisted to localStorage (MapAppearance + migration version) */
@@ -39,7 +43,11 @@ type SetShowRoadsAction = Readonly<{
39
43
  type: "setShowRoads";
40
44
  payload: boolean;
41
45
  }>;
42
- export type AppearanceAction = Readonly<SetThemeAction | SetMapTypeAction | SetShowRoadsAction>;
46
+ type SetShowTrafficAction = Readonly<{
47
+ type: "setShowTraffic";
48
+ payload: boolean;
49
+ }>;
50
+ export type AppearanceAction = Readonly<SetThemeAction | SetMapTypeAction | SetShowRoadsAction | SetShowTrafficAction>;
43
51
  /** Reducer for persisted appearance state */
44
52
  export declare const appearanceReducer: (state: PersistedAppearance, action: AppearanceAction) => PersistedAppearance;
45
53
  /** Configuration for which appearance options to show in the settings panel */
@@ -50,6 +58,8 @@ export type AppearanceOptions = Readonly<{
50
58
  mapTypes?: ReadonlyArray<MapType>;
51
59
  /** Whether to show the roads toggle when satellite/hybrid is selected. Default: true */
52
60
  roads?: boolean;
61
+ /** Whether to show the traffic toggle when the adapter supports traffic. Default: true */
62
+ traffic?: boolean;
53
63
  }>;
54
64
  /**
55
65
  * Narrowed API surface that useMapAppearanceControls actually needs.
@@ -11,6 +11,8 @@ import { type UseMapAppearanceControlsParams } from "./appearanceTypes";
11
11
  * Returns an array with a single `MenuControlConfig` that contains:
12
12
  * - A custom item with a grid of live tiny map previews
13
13
  * - A toggle item for roads overlay (when satellite/hybrid is selected)
14
+ * - A toggle item for live traffic (when the adapter supports it; disabled on
15
+ * satellite while roads are off, since traffic only renders on hybrid)
14
16
  *
15
17
  * @example
16
18
  * ```tsx
@@ -4,6 +4,15 @@ type MenuBooleanItemProps = Readonly<{
4
4
  children: ReactNode;
5
5
  className?: string;
6
6
  "data-testid"?: string;
7
+ disabled?: boolean;
8
+ /** Why the control is disabled; shown as a tooltip over the whole row */
9
+ disabledReason?: string;
10
+ /**
11
+ * Id for the visually hidden copy of the reason, so callers can point the
12
+ * input's aria-describedby at it (tooltips are hover-only: invisible to
13
+ * touch, keyboard, and screen readers).
14
+ */
15
+ disabledReasonId?: string;
7
16
  icon?: IconName;
8
17
  label: string;
9
18
  }>;
@@ -14,7 +23,11 @@ type MenuBooleanItemProps = Readonly<{
14
23
  * The caller provides the specific input control (ToggleSwitch or Checkbox)
15
24
  * as children.
16
25
  *
26
+ * The disabled reason renders as a tooltip on the row itself, not on the
27
+ * input: the full-width dimmed row is what users hover, and a natively
28
+ * disabled input (checkbox case) suppresses its own hover events anyway.
29
+ *
17
30
  * @internal
18
31
  */
19
- export declare const MenuBooleanItem: ({ children, className, "data-testid": dataTestId, icon, label, }: MenuBooleanItemProps) => import("react").JSX.Element;
32
+ export declare const MenuBooleanItem: ({ children, className, "data-testid": dataTestId, disabled, disabledReason, disabledReasonId, icon, label, }: MenuBooleanItemProps) => import("react").JSX.Element;
20
33
  export {};
@@ -87,6 +87,24 @@ type BooleanControlFields = Readonly<{
87
87
  label: string;
88
88
  value: boolean;
89
89
  onChange: (value: boolean) => void;
90
+ /**
91
+ * Disables interaction and dims the control while keeping it visible,
92
+ * for options that only apply in the current context (e.g. the traffic
93
+ * toggle on satellite while roads are off).
94
+ *
95
+ * Rendering differs deliberately per control: menu toggles map this to
96
+ * ToggleSwitch's `readOnly` (announced as dimmed, keeps its switch role;
97
+ * native `disabled` would drop focusability and get skipped in some AT
98
+ * browse modes), while checkboxes use native `disabled` because Checkbox
99
+ * forwards `readOnly` to an input type="checkbox", where it is inert and
100
+ * does not block label-dispatched activation.
101
+ */
102
+ disabled?: boolean;
103
+ /**
104
+ * Why the control is disabled, shown as a tooltip on the disabled control
105
+ * so users learn what unblocks it (e.g. "Turn on Roads to show traffic").
106
+ */
107
+ disabledReason?: string;
90
108
  /**
91
109
  * State-dependent icons shown when the control is on or off (standalone only).
92
110
  * Both must be provided together — this prevents partial-icon bugs where
@@ -79,6 +79,19 @@ export type MapActions = Readonly<{
79
79
  * @returns Promise that resolves when the change is applied
80
80
  */
81
81
  setShowRoads: (showRoads: boolean) => Promise<void>;
82
+ /**
83
+ * Set whether live traffic conditions are shown on the map.
84
+ *
85
+ * Undefined when the adapter does not support traffic rendering — UI such as
86
+ * the built-in map-style control hides its traffic toggle in that case.
87
+ * The value records the user's preference; the adapter may still suppress
88
+ * rendering where the provider cannot draw traffic (plain satellite) or
89
+ * below TRAFFIC_MIN_ZOOM.
90
+ *
91
+ * @param showTraffic - Whether traffic should be shown
92
+ * @returns Promise that resolves when the change is applied
93
+ */
94
+ setShowTraffic?: (showTraffic: boolean) => Promise<void>;
82
95
  }>;
83
96
  /**
84
97
  * The API object returned as the second element of the useMap tuple.
@@ -1 +0,0 @@
1
- {"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}