@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 +165 -33
- package/index.esm.js +165 -33
- package/package.json +10 -10
- package/src/appearance/appearanceTypes.d.ts +17 -7
- package/src/appearance/useMapAppearanceControls.d.ts +2 -0
- package/src/controls/renderers/boolean/MenuBooleanItem.d.ts +14 -1
- package/src/controls/types.d.ts +18 -0
- package/src/core/types.d.ts +13 -0
- package/migrations/entry.js.map +0 -1
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
|
-
|
|
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, }) =>
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
],
|
|
2115
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
|
2779
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
2815
|
-
|
|
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:
|
|
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: {
|
|
2919
|
-
|
|
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
|
-
|
|
2971
|
-
|
|
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
|
-
|
|
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, }) =>
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
],
|
|
2114
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
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
|
|
2778
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
2814
|
-
|
|
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:
|
|
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: {
|
|
2918
|
-
|
|
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
|
-
|
|
2970
|
-
|
|
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.
|
|
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.
|
|
11
|
-
"@trackunit/css-class-variance-utilities": "2.0.
|
|
12
|
-
"@trackunit/react-form-components": "2.7.
|
|
13
|
-
"@trackunit/react-core-hooks": "1.22.
|
|
14
|
-
"@trackunit/geo-json-utils": "1.15.
|
|
15
|
-
"@trackunit/i18n-library-translation": "2.5.
|
|
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.
|
|
18
|
-
"@trackunit/react-map-color-utils": "0.0.
|
|
19
|
-
"@trackunit/ui-design-tokens": "1.15.
|
|
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
|
|
6
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
showTraffic: boolean;
|
|
21
|
+
version: 2;
|
|
19
22
|
showRoads: boolean;
|
|
20
23
|
}, {
|
|
21
24
|
mapType: "roadmap" | "satellite" | "hybrid";
|
|
22
25
|
theme: "light" | "dark";
|
|
23
|
-
|
|
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
|
-
|
|
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 {};
|
package/src/controls/types.d.ts
CHANGED
|
@@ -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
|
package/src/core/types.d.ts
CHANGED
|
@@ -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.
|
package/migrations/entry.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
|