@trackunit/react-map 0.2.1 → 0.2.3

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
@@ -12,7 +12,6 @@ var reactDom = require('react-dom');
12
12
  var esToolkit = require('es-toolkit');
13
13
  var reactCoreHooks = require('@trackunit/react-core-hooks');
14
14
  var reactFormComponents = require('@trackunit/react-form-components');
15
- var reactModal = require('@trackunit/react-modal');
16
15
  var zod = require('zod');
17
16
  var reactMinimalPieChart = require('react-minimal-pie-chart');
18
17
  var uiDesignTokens = require('@trackunit/ui-design-tokens');
@@ -38,6 +37,7 @@ var defaultTranslations = {
38
37
  "map.loadingData": "Loading map data",
39
38
  "map.loadingErrorMessage": "Unable to load the map. Please try again later.",
40
39
  "multiGeometry.badge.tooltip": "Part of a multi-part shape",
40
+ "panels.close": "Close",
41
41
  "settings.mapStyle.dark": "Dark",
42
42
  "settings.mapStyle.light": "Light",
43
43
  "settings.mapStyle.roadmap": "Road map",
@@ -1180,13 +1180,14 @@ const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder
1180
1180
  const containerHeight = geometry?.height ?? Infinity;
1181
1181
  const availableSpace = breakpoints.isSm && containerHeight >= HEIGHT_COMFORTABLE_THRESHOLD ? "comfortable" : "constrained";
1182
1182
  const responsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
1183
+ const [safeAreaContentEl, setSafeAreaContentEl] = react.useState(null);
1184
+ const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = react.useState(null);
1183
1185
  const layout = react.useMemo(() => ({
1184
1186
  availableSpace,
1185
1187
  responsiveMode,
1186
- }), [availableSpace, responsiveMode]);
1188
+ safeAreaContentEl,
1189
+ }), [availableSpace, responsiveMode, safeAreaContentEl]);
1187
1190
  const mapMoveSource = react.useMemo(() => ({ on: adapter.on.bind(adapter) }), []);
1188
- const [safeAreaContentEl, setSafeAreaContentEl] = react.useState(null);
1189
- const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = react.useState(null);
1190
1191
  const content = (jsxRuntime.jsxs("div", { "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel ?? "Interactive map", className: tailwindMerge.twMerge(
1191
1192
  // ZStack: full-size grid with a single cell so children overlay the map
1192
1193
  "isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxRuntime.jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsxRuntime.jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsxRuntime.jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsxRuntime.jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsxRuntime.jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsxRuntime.jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, mapMoveSource: mapMoveSource, store: panelStore })) : null, jsxRuntime.jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxRuntime.jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsxRuntime.jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsxRuntime.jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
@@ -2127,13 +2128,26 @@ const MenuSearch = ({ config, resolved: _resolved }) => {
2127
2128
  * Purely presentational -- all rendering decisions come from resolved props.
2128
2129
  * `data-testid` (when set) is on the menu-item wrapper only — not duplicated on the inner `SelectField`.
2129
2130
  *
2131
+ * The dropdown portals outside the menu's clipped/scrolled chrome (`overflow-clip`/`overflow-y-auto`
2132
+ * on `PopoverContent`/`MenuContent`) so long option lists are never truncated -- same default
2133
+ * behavior as the standalone toolbar `Select`. In fullscreen mode it portals into the map's own
2134
+ * `customPortalId` element instead of `document.body`, since real `document.body` content is
2135
+ * invisible while the map container is in the Fullscreen API (mirrors `PopoverContent`'s
2136
+ * `portalId={isFullScreen ? customPortalId : undefined}` pattern).
2137
+ *
2138
+ * The dropdown portal always stacks at the `z-popover` layer (`SelectField`'s default), the same
2139
+ * layer as the menu's own `PopoverContent` -- so it reliably paints on top rather than underneath
2140
+ * the menu wherever their bounds overlap, since both portal near the top of the DOM tree outside
2141
+ * the menu's own clipped subtree.
2142
+ *
2130
2143
  * @internal
2131
2144
  */
2132
- const MenuSelect = ({ config, resolved: _resolved }) => {
2145
+ const MenuSelect = ({ config, customPortalId, isFullScreen, resolved: _resolved }) => {
2133
2146
  const { className, "data-testid": dataTestId, id, label, onChange, options, value } = config;
2147
+ const menuPortalTarget = isFullScreen && customPortalId !== undefined ? (document.getElementById(customPortalId) ?? undefined) : undefined;
2134
2148
  return (jsxRuntime.jsx("div", { className: cvaControlMenuItemBase({
2135
2149
  className: ["block", className],
2136
- }), "data-testid": dataTestId, children: jsxRuntime.jsx(reactFormComponents.SelectField, { className: undefined, "data-testid": undefined, id: id, label: label, menuPortalTarget: null, onChange: event => {
2150
+ }), "data-testid": dataTestId, children: jsxRuntime.jsx(reactFormComponents.SelectField, { className: undefined, "data-testid": undefined, id: id, label: label, menuPortalTarget: menuPortalTarget, onChange: event => {
2137
2151
  const newValue = event.target.value;
2138
2152
  if (newValue !== undefined) {
2139
2153
  onChange(newValue);
@@ -2217,22 +2231,6 @@ const MenuToggleGroup = ({ config, resolved }) => {
2217
2231
  }, isIconOnly: false, size: resolved.size }) }));
2218
2232
  };
2219
2233
 
2220
- /**
2221
- * Renders a menu control as a modal instead of a popover.
2222
- * Used on narrow containers where popovers would be clipped or awkward.
2223
- *
2224
- * The trigger is the same IconButton used by the popover path.
2225
- * The content is rendered inside a Modal with ModalHeader (label + close)
2226
- * and ModalBody (scrollable menu items).
2227
- *
2228
- * @internal
2229
- */
2230
- const Modal = ({ config, resolved, children }) => {
2231
- const { className, "data-testid": dataTestId, label } = config;
2232
- const modal = reactModal.useModal({ role: "dialog" });
2233
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.IconButton, { "aria-label": label, className: className, "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), onClick: modal.open, size: resolved.size, title: label, variant: "secondary" }), jsxRuntime.jsxs(reactModal.Modal, { ...modal, "data-testid": dataTestId ? `${dataTestId}-modal` : undefined, size: "small", children: [jsxRuntime.jsx(reactModal.ModalHeader, { "data-testid": dataTestId ? `${dataTestId}-modal` : undefined, heading: label, onClickClose: modal.close }), jsxRuntime.jsx(reactModal.ModalBody, { children: children })] })] }));
2234
- };
2235
-
2236
2234
  /**
2237
2235
  * Renders a section header within a menu.
2238
2236
  * Non-interactive, purely visual grouping element.
@@ -2253,7 +2251,7 @@ const Separator = ({ className }) => (jsxRuntime.jsx("div", { className: tailwin
2253
2251
  * Calls resolveControl with placement: "menu" for each item.
2254
2252
  * The sizing middleware will always derive compact sizing for menu items.
2255
2253
  */
2256
- const renderMenuItem = (item, index, availableSpace) => {
2254
+ const renderMenuItem = (item, index, availableSpace, customPortalId, isFullScreen) => {
2257
2255
  const resolved = resolveControl(item, { placement: "menu", availableSpace });
2258
2256
  switch (item.type) {
2259
2257
  case "separator":
@@ -2265,7 +2263,7 @@ const renderMenuItem = (item, index, availableSpace) => {
2265
2263
  case "button":
2266
2264
  return jsxRuntime.jsx(MenuButton, { config: item, resolved: resolved }, item.id);
2267
2265
  case "select":
2268
- return jsxRuntime.jsx(MenuSelect, { config: item, resolved: resolved }, item.id);
2266
+ return (jsxRuntime.jsx(MenuSelect, { config: item, customPortalId: customPortalId, isFullScreen: isFullScreen, resolved: resolved }, item.id));
2269
2267
  case "radioGroup":
2270
2268
  return jsxRuntime.jsx(MenuRadioGroup, { config: item, resolved: resolved }, item.id);
2271
2269
  case "toggleGroup":
@@ -2305,53 +2303,44 @@ const groupItemsBySection = (items) => {
2305
2303
  return groups;
2306
2304
  };
2307
2305
  /**
2308
- * Renders the grouped menu items with section headers.
2309
- * Shared between the popover and modal presentation paths.
2306
+ * Renders the grouped menu items with section headers as a plain block list inside the popover's
2307
+ * own card styling (border, background, shadow).
2310
2308
  *
2311
- * In a popover, the items need their own card styling (border, background, shadow).
2312
- * In a modal, {@link Modal} already provides header/body chrome, so the items
2313
- * render without additional outer card styling to avoid a nested-shell appearance.
2309
+ * Scrolls internally (rather than on the outer `PopoverContent`) so the native scrollbar stays
2310
+ * inside this div's own rounded border instead of riding the popover's unstyled outer edge --
2311
+ * mirrors the cvaMenu/cvaMenuList split used elsewhere for the same reason.
2314
2312
  */
2315
- const MenuContent = ({ availableSpace, items, presentation }) => {
2313
+ const MenuContent = ({ availableSpace, customPortalId, isFullScreen, items }) => {
2316
2314
  const groups = groupItemsBySection(items);
2317
- return (jsxRuntime.jsx("div", { className: presentation === "popover" ? "rounded-lg border border-neutral-300 bg-white py-1 shadow" : "py-1", role: "menu", children: groups.map((group, groupIndex) => (jsxRuntime.jsxs(react.Fragment, { children: [group.section !== undefined ? jsxRuntime.jsx(SectionHeader, { title: group.section }) : null, group.items.map((item, itemIndex) => renderMenuItem(item, groupIndex * 100 + itemIndex, availableSpace))] }, groupIndex))) }));
2315
+ return (jsxRuntime.jsx("div", { className: "max-h-[max(40dvh,var(--spacing-64))] overflow-y-auto rounded-lg border border-neutral-300 bg-white py-1 shadow", role: "menu", children: groups.map((group, groupIndex) => (jsxRuntime.jsxs(react.Fragment, { children: [group.section !== undefined ? jsxRuntime.jsx(SectionHeader, { title: group.section }) : null, group.items.map((item, itemIndex) => renderMenuItem(item, groupIndex * 100 + itemIndex, availableSpace, customPortalId, isFullScreen))] }, groupIndex))) }));
2318
2316
  };
2319
2317
  /** Popover opens right; tooltip is suppressed while the menu is open. */
2320
2318
  const MenuRightPopover = ({ availableSpace, config, customPortalId, resolved, }) => {
2321
2319
  const { className, "data-testid": dataTestId, items, label } = config;
2322
2320
  const isFullScreen = reactComponents.useIsFullscreen();
2323
- return (jsxRuntime.jsx("div", { className: className ?? "w-fit", children: jsxRuntime.jsx(reactComponents.Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxRuntime.jsxs(react.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: label, "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { portalId: isFullScreen ? customPortalId : undefined, children: jsxRuntime.jsx(MenuContent, { availableSpace: availableSpace, items: items, presentation: "popover" }) })] })) }) }));
2321
+ return (jsxRuntime.jsx("div", { className: className ?? "w-fit", children: jsxRuntime.jsx(reactComponents.Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxRuntime.jsxs(react.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: label, "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsxRuntime.jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] })) }) }));
2324
2322
  };
2325
- /** Popover presentation -- renders a MoreMenu with position-aware placement. */
2323
+ /** Popover presentation -- renders a position-aware popover anchored to the trigger button. */
2326
2324
  const MenuAutoPopover = ({ availableSpace, config, containerRef, customPortalId, resolved, }) => {
2327
2325
  const { className, "data-testid": dataTestId, items, label } = config;
2328
2326
  const isFullScreen = reactComponents.useIsFullscreen();
2329
2327
  const triggerRef = react.useRef(null);
2330
2328
  const popoverPlacement = usePopoverPlacement(triggerRef, containerRef);
2331
- return (jsxRuntime.jsx("div", { className: "w-fit", ref: triggerRef, children: jsxRuntime.jsx(reactComponents.MoreMenu, { className: className, customButton: jsxRuntime.jsx(reactComponents.IconButton, { "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, title: label, variant: "secondary" }), customPortalId: isFullScreen ? customPortalId : undefined, "data-testid": dataTestId, popoverProps: { placement: popoverPlacement }, children: jsxRuntime.jsx(MenuContent, { availableSpace: availableSpace, items: items, presentation: "popover" }) }) }));
2329
+ return (jsxRuntime.jsx("div", { className: className ?? "w-fit", ref: triggerRef, children: jsxRuntime.jsxs(reactComponents.Popover, { placement: popoverPlacement, children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.IconButton, { "data-testid": dataTestId, icon: jsxRuntime.jsx(reactComponents.Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, title: label, variant: "secondary" }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsxRuntime.jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] }) }));
2332
2330
  };
2333
- const MenuPopover = ({ availableSpace, config, containerRef, customPortalId, overlaySide = undefined, resolved, }) => {
2334
- if (overlaySide === "right") {
2335
- return (jsxRuntime.jsx(MenuRightPopover, { availableSpace: availableSpace, config: config, customPortalId: customPortalId, resolved: resolved }));
2336
- }
2337
- return (jsxRuntime.jsx(MenuAutoPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, resolved: resolved }));
2338
- };
2339
- /** Modal presentation -- renders a Modal with no popover-specific hooks. */
2340
- const MenuModal = ({ availableSpace, config, resolved }) => (jsxRuntime.jsx(Modal, { config: config, resolved: resolved, children: jsxRuntime.jsx(MenuContent, { availableSpace: availableSpace, items: config.items, presentation: "modal" }) }));
2341
2331
  /**
2342
- * Renders a menu control as either a popover or a modal,
2343
- * depending on the `menuPresentation` prop derived from container size.
2332
+ * Renders a menu control as a popover -- either anchored to the trigger (auto-placed) or
2333
+ * opening to a fixed right placement, depending on `overlaySide`.
2344
2334
  *
2345
- * Delegates to `MenuPopover` or `MenuModal` so each path
2346
- * only runs the hooks it needs.
2335
+ * Delegates to sub-components so each path only runs the hooks it needs.
2347
2336
  *
2348
2337
  * @internal
2349
2338
  */
2350
- const Menu = ({ availableSpace, config, containerRef, customPortalId, menuPresentation, overlaySide = undefined, resolved, }) => {
2351
- if (menuPresentation === "modal") {
2352
- return jsxRuntime.jsx(MenuModal, { availableSpace: availableSpace, config: config, resolved: resolved });
2339
+ const Menu = ({ availableSpace, config, containerRef, customPortalId, overlaySide = undefined, resolved, }) => {
2340
+ if (overlaySide === "right") {
2341
+ return (jsxRuntime.jsx(MenuRightPopover, { availableSpace: availableSpace, config: config, customPortalId: customPortalId, resolved: resolved }));
2353
2342
  }
2354
- return (jsxRuntime.jsx(MenuPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, overlaySide: overlaySide, resolved: resolved }));
2343
+ return (jsxRuntime.jsx(MenuAutoPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, resolved: resolved }));
2355
2344
  };
2356
2345
 
2357
2346
  const fieldConfigForStandalonePopover = (config) => {
@@ -2482,7 +2471,7 @@ const ToggleGroup = ({ config, overlaySide: _overlaySide = undefined, resolved }
2482
2471
  *
2483
2472
  * @internal
2484
2473
  */
2485
- const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId, menuPresentation, overlaySide = undefined, }) => {
2474
+ const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId, overlaySide = undefined, }) => {
2486
2475
  const resolved = resolveControl(control, { placement: "standalone", availableSpace });
2487
2476
  switch (control.type) {
2488
2477
  case "button":
@@ -2501,7 +2490,7 @@ const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId
2501
2490
  case "toggleGroup":
2502
2491
  return jsxRuntime.jsx(ToggleGroup, { config: control, overlaySide: overlaySide, resolved: resolved });
2503
2492
  case "menu":
2504
- return (jsxRuntime.jsx(Menu, { availableSpace: availableSpace, config: control, containerRef: containerRef, customPortalId: customPortalId, menuPresentation: menuPresentation, overlaySide: overlaySide, resolved: resolved }));
2493
+ return (jsxRuntime.jsx(Menu, { availableSpace: availableSpace, config: control, containerRef: containerRef, customPortalId: customPortalId, overlaySide: overlaySide, resolved: resolved }));
2505
2494
  case "custom":
2506
2495
  return jsxRuntime.jsx(Custom, { config: control, overlaySide: overlaySide, placement: "standalone", resolved: resolved });
2507
2496
  default: {
@@ -2543,8 +2532,6 @@ const ControlsContent = ({ api, controls: baseControlsConfig, className }) => {
2543
2532
  const measuredResponsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
2544
2533
  const availableSpace = safeAreaLayout?.availableSpace ?? measuredAvailableSpace;
2545
2534
  const responsiveMode = safeAreaLayout?.responsiveMode ?? measuredResponsiveMode;
2546
- // Menus use modals only in collapsed mode (narrowest containers); popovers otherwise
2547
- const menuPresentation = responsiveMode === "collapsed" ? "modal" : "popover";
2548
2535
  // Derive portal ID from the map container's stable ID (set by createMapComponent)
2549
2536
  const portalId = api.containerRef.current?.id;
2550
2537
  // Geolocation position for region-appropriate globe icon (updated if the user clicks "My Location")
@@ -2580,7 +2567,7 @@ const ControlsContent = ({ api, controls: baseControlsConfig, className }) => {
2580
2567
  // Responsive grid: navigation bottom-right, settings top-left, tools top-right, statistics bottom-left
2581
2568
  "grid-cols-[1fr_auto] grid-rows-[auto_1fr_auto]",
2582
2569
  // Isolate creates a new stacking context to avoid z-index issues
2583
- "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasNavigationControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1 self-end justify-self-end", children: navigation.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasSettingsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation, overlaySide: "right" }, control.id))) })) : null, hasToolsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-1 flex flex-col gap-1 justify-self-end", "data-testid": "map-controls-tools", children: tools.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsxRuntime.jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsxRuntime.jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null] })) : null] }));
2570
+ "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasNavigationControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1 self-end justify-self-end", children: navigation.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null, hasSettingsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, overlaySide: "right" }, control.id))) })) : null, hasToolsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto col-start-2 row-start-1 flex flex-col gap-1 justify-self-end", "data-testid": "map-controls-tools", children: tools.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsxRuntime.jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsxRuntime.jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsxRuntime.jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsxRuntime.jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null] })) : null] }));
2584
2571
  };
2585
2572
  /**
2586
2573
  * Map controls renderer — lays out a `ControlsConfig` across the four semantic
@@ -2686,7 +2673,7 @@ const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMa
2686
2673
  }
2687
2674
  return configs;
2688
2675
  }, [adapterConfig, options]);
2689
- return (jsxRuntime.jsx("div", { className: "flex items-start gap-2 overflow-x-auto px-3 py-2", role: "radiogroup", children: options.map(option => {
2676
+ return (jsxRuntime.jsx("div", { className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2690
2677
  const derivedConfig = derivedConfigs.get(option.theme);
2691
2678
  if (derivedConfig === undefined) {
2692
2679
  return null;
package/index.esm.js CHANGED
@@ -11,7 +11,6 @@ import { createPortal } from 'react-dom';
11
11
  import { isEqual } from 'es-toolkit';
12
12
  import { useGeolocation } from '@trackunit/react-core-hooks';
13
13
  import { Checkbox, ToggleSwitch, RadioGroup as RadioGroup$1, RadioItem, FormGroup, Search as Search$1, SelectField, BaseSelect } from '@trackunit/react-form-components';
14
- import { useModal, Modal as Modal$1, ModalHeader, ModalBody } from '@trackunit/react-modal';
15
14
  import { z } from 'zod';
16
15
  import { PieChart } from 'react-minimal-pie-chart';
17
16
  import { tailwindPalette } from '@trackunit/ui-design-tokens';
@@ -37,6 +36,7 @@ var defaultTranslations = {
37
36
  "map.loadingData": "Loading map data",
38
37
  "map.loadingErrorMessage": "Unable to load the map. Please try again later.",
39
38
  "multiGeometry.badge.tooltip": "Part of a multi-part shape",
39
+ "panels.close": "Close",
40
40
  "settings.mapStyle.dark": "Dark",
41
41
  "settings.mapStyle.light": "Light",
42
42
  "settings.mapStyle.roadmap": "Road map",
@@ -1179,13 +1179,14 @@ const createMapComponent = ({ adapter, Renderer, adapterName, containerRefHolder
1179
1179
  const containerHeight = geometry?.height ?? Infinity;
1180
1180
  const availableSpace = breakpoints.isSm && containerHeight >= HEIGHT_COMFORTABLE_THRESHOLD ? "comfortable" : "constrained";
1181
1181
  const responsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
1182
+ const [safeAreaContentEl, setSafeAreaContentEl] = useState(null);
1183
+ const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = useState(null);
1182
1184
  const layout = useMemo(() => ({
1183
1185
  availableSpace,
1184
1186
  responsiveMode,
1185
- }), [availableSpace, responsiveMode]);
1187
+ safeAreaContentEl,
1188
+ }), [availableSpace, responsiveMode, safeAreaContentEl]);
1186
1189
  const mapMoveSource = useMemo(() => ({ on: adapter.on.bind(adapter) }), []);
1187
- const [safeAreaContentEl, setSafeAreaContentEl] = useState(null);
1188
- const [safeAreaPanelPortalEl, setSafeAreaPanelPortalEl] = useState(null);
1189
1190
  const content = (jsxs("div", { "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel ?? "Interactive map", className: twMerge(
1190
1191
  // ZStack: full-size grid with a single cell so children overlay the map
1191
1192
  "isolate grid h-full w-full grid-cols-1 grid-rows-1", className), "data-testid": dataTestId, id: containerId, ref: mergedRef, role: "application", style: style, tabIndex: 0, children: [jsxs("div", { className: "relative col-start-1 row-start-1 grid h-full min-h-[200px] w-full", children: [jsx(Renderer, { adapterInstance: adapter, className: "h-full w-full", style: { height: "100%", width: "100%" } }), jsx(AdapterReadinessOverlay, { adapter: adapter })] }), jsx("div", { className: "pointer-events-none absolute inset-0 col-start-1 row-start-1 overflow-visible", "data-testid": "map-marker-overlay-portal" }), jsx("div", { className: "pointer-events-none relative isolate col-start-1 row-start-1 h-full min-h-0 w-full overflow-clip", "data-testid": "map-panel-overlay-portal", ref: setSafeAreaPanelPortalEl }), panelStore !== undefined ? (jsx(PanelOrchestrator, { anchoredPortalEl: safeAreaPanelPortalEl, containerEl: safeAreaContentEl ?? containerEl, mapMoveSource: mapMoveSource, store: panelStore })) : null, jsx(SafeAreaLayoutContext.Provider, { value: layout, children: jsxs(SafeAreaOverlay, { safeAreaContentRef: setSafeAreaContentEl, safeAreaInsets: safeAreaInsets ?? null, children: [children, annotationStore !== undefined ? jsx(FallbackAnnotationOverlay, { store: annotationStore }) : null, loadingIndicatorStore !== undefined ? (jsx(LoadingOverlay, { adapter: adapter, store: loadingIndicatorStore })) : null] }) })] }));
@@ -2126,13 +2127,26 @@ const MenuSearch = ({ config, resolved: _resolved }) => {
2126
2127
  * Purely presentational -- all rendering decisions come from resolved props.
2127
2128
  * `data-testid` (when set) is on the menu-item wrapper only — not duplicated on the inner `SelectField`.
2128
2129
  *
2130
+ * The dropdown portals outside the menu's clipped/scrolled chrome (`overflow-clip`/`overflow-y-auto`
2131
+ * on `PopoverContent`/`MenuContent`) so long option lists are never truncated -- same default
2132
+ * behavior as the standalone toolbar `Select`. In fullscreen mode it portals into the map's own
2133
+ * `customPortalId` element instead of `document.body`, since real `document.body` content is
2134
+ * invisible while the map container is in the Fullscreen API (mirrors `PopoverContent`'s
2135
+ * `portalId={isFullScreen ? customPortalId : undefined}` pattern).
2136
+ *
2137
+ * The dropdown portal always stacks at the `z-popover` layer (`SelectField`'s default), the same
2138
+ * layer as the menu's own `PopoverContent` -- so it reliably paints on top rather than underneath
2139
+ * the menu wherever their bounds overlap, since both portal near the top of the DOM tree outside
2140
+ * the menu's own clipped subtree.
2141
+ *
2129
2142
  * @internal
2130
2143
  */
2131
- const MenuSelect = ({ config, resolved: _resolved }) => {
2144
+ const MenuSelect = ({ config, customPortalId, isFullScreen, resolved: _resolved }) => {
2132
2145
  const { className, "data-testid": dataTestId, id, label, onChange, options, value } = config;
2146
+ const menuPortalTarget = isFullScreen && customPortalId !== undefined ? (document.getElementById(customPortalId) ?? undefined) : undefined;
2133
2147
  return (jsx("div", { className: cvaControlMenuItemBase({
2134
2148
  className: ["block", className],
2135
- }), "data-testid": dataTestId, children: jsx(SelectField, { className: undefined, "data-testid": undefined, id: id, label: label, menuPortalTarget: null, onChange: event => {
2149
+ }), "data-testid": dataTestId, children: jsx(SelectField, { className: undefined, "data-testid": undefined, id: id, label: label, menuPortalTarget: menuPortalTarget, onChange: event => {
2136
2150
  const newValue = event.target.value;
2137
2151
  if (newValue !== undefined) {
2138
2152
  onChange(newValue);
@@ -2216,22 +2230,6 @@ const MenuToggleGroup = ({ config, resolved }) => {
2216
2230
  }, isIconOnly: false, size: resolved.size }) }));
2217
2231
  };
2218
2232
 
2219
- /**
2220
- * Renders a menu control as a modal instead of a popover.
2221
- * Used on narrow containers where popovers would be clipped or awkward.
2222
- *
2223
- * The trigger is the same IconButton used by the popover path.
2224
- * The content is rendered inside a Modal with ModalHeader (label + close)
2225
- * and ModalBody (scrollable menu items).
2226
- *
2227
- * @internal
2228
- */
2229
- const Modal = ({ config, resolved, children }) => {
2230
- const { className, "data-testid": dataTestId, label } = config;
2231
- const modal = useModal({ role: "dialog" });
2232
- return (jsxs(Fragment, { children: [jsx(IconButton, { "aria-label": label, className: className, "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), onClick: modal.open, size: resolved.size, title: label, variant: "secondary" }), jsxs(Modal$1, { ...modal, "data-testid": dataTestId ? `${dataTestId}-modal` : undefined, size: "small", children: [jsx(ModalHeader, { "data-testid": dataTestId ? `${dataTestId}-modal` : undefined, heading: label, onClickClose: modal.close }), jsx(ModalBody, { children: children })] })] }));
2233
- };
2234
-
2235
2233
  /**
2236
2234
  * Renders a section header within a menu.
2237
2235
  * Non-interactive, purely visual grouping element.
@@ -2252,7 +2250,7 @@ const Separator = ({ className }) => (jsx("div", { className: twMerge("my-1 h-px
2252
2250
  * Calls resolveControl with placement: "menu" for each item.
2253
2251
  * The sizing middleware will always derive compact sizing for menu items.
2254
2252
  */
2255
- const renderMenuItem = (item, index, availableSpace) => {
2253
+ const renderMenuItem = (item, index, availableSpace, customPortalId, isFullScreen) => {
2256
2254
  const resolved = resolveControl(item, { placement: "menu", availableSpace });
2257
2255
  switch (item.type) {
2258
2256
  case "separator":
@@ -2264,7 +2262,7 @@ const renderMenuItem = (item, index, availableSpace) => {
2264
2262
  case "button":
2265
2263
  return jsx(MenuButton, { config: item, resolved: resolved }, item.id);
2266
2264
  case "select":
2267
- return jsx(MenuSelect, { config: item, resolved: resolved }, item.id);
2265
+ return (jsx(MenuSelect, { config: item, customPortalId: customPortalId, isFullScreen: isFullScreen, resolved: resolved }, item.id));
2268
2266
  case "radioGroup":
2269
2267
  return jsx(MenuRadioGroup, { config: item, resolved: resolved }, item.id);
2270
2268
  case "toggleGroup":
@@ -2304,53 +2302,44 @@ const groupItemsBySection = (items) => {
2304
2302
  return groups;
2305
2303
  };
2306
2304
  /**
2307
- * Renders the grouped menu items with section headers.
2308
- * Shared between the popover and modal presentation paths.
2305
+ * Renders the grouped menu items with section headers as a plain block list inside the popover's
2306
+ * own card styling (border, background, shadow).
2309
2307
  *
2310
- * In a popover, the items need their own card styling (border, background, shadow).
2311
- * In a modal, {@link Modal} already provides header/body chrome, so the items
2312
- * render without additional outer card styling to avoid a nested-shell appearance.
2308
+ * Scrolls internally (rather than on the outer `PopoverContent`) so the native scrollbar stays
2309
+ * inside this div's own rounded border instead of riding the popover's unstyled outer edge --
2310
+ * mirrors the cvaMenu/cvaMenuList split used elsewhere for the same reason.
2313
2311
  */
2314
- const MenuContent = ({ availableSpace, items, presentation }) => {
2312
+ const MenuContent = ({ availableSpace, customPortalId, isFullScreen, items }) => {
2315
2313
  const groups = groupItemsBySection(items);
2316
- return (jsx("div", { className: presentation === "popover" ? "rounded-lg border border-neutral-300 bg-white py-1 shadow" : "py-1", role: "menu", children: groups.map((group, groupIndex) => (jsxs(Fragment$1, { children: [group.section !== undefined ? jsx(SectionHeader, { title: group.section }) : null, group.items.map((item, itemIndex) => renderMenuItem(item, groupIndex * 100 + itemIndex, availableSpace))] }, groupIndex))) }));
2314
+ return (jsx("div", { className: "max-h-[max(40dvh,var(--spacing-64))] overflow-y-auto rounded-lg border border-neutral-300 bg-white py-1 shadow", role: "menu", children: groups.map((group, groupIndex) => (jsxs(Fragment$1, { children: [group.section !== undefined ? jsx(SectionHeader, { title: group.section }) : null, group.items.map((item, itemIndex) => renderMenuItem(item, groupIndex * 100 + itemIndex, availableSpace, customPortalId, isFullScreen))] }, groupIndex))) }));
2317
2315
  };
2318
2316
  /** Popover opens right; tooltip is suppressed while the menu is open. */
2319
2317
  const MenuRightPopover = ({ availableSpace, config, customPortalId, resolved, }) => {
2320
2318
  const { className, "data-testid": dataTestId, items, label } = config;
2321
2319
  const isFullScreen = useIsFullscreen();
2322
- return (jsx("div", { className: className ?? "w-fit", children: jsx(Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxs(Fragment$1, { children: [jsx(PopoverTrigger, { children: jsx(Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsx(IconButton, { ariaLabel: label, "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsx(PopoverContent, { portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, items: items, presentation: "popover" }) })] })) }) }));
2320
+ return (jsx("div", { className: className ?? "w-fit", children: jsx(Popover, { placement: RIGHT_CONTROL_POPOVER_PLACEMENT, children: ({ isOpen }) => (jsxs(Fragment$1, { children: [jsx(PopoverTrigger, { children: jsx(Tooltip, { delayed: true, disabled: isOpen, interactable: false, label: label, placement: RIGHT_CONTROL_TOOLTIP_PLACEMENT, children: jsx(IconButton, { ariaLabel: label, "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, variant: "secondary" }) }) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] })) }) }));
2323
2321
  };
2324
- /** Popover presentation -- renders a MoreMenu with position-aware placement. */
2322
+ /** Popover presentation -- renders a position-aware popover anchored to the trigger button. */
2325
2323
  const MenuAutoPopover = ({ availableSpace, config, containerRef, customPortalId, resolved, }) => {
2326
2324
  const { className, "data-testid": dataTestId, items, label } = config;
2327
2325
  const isFullScreen = useIsFullscreen();
2328
2326
  const triggerRef = useRef(null);
2329
2327
  const popoverPlacement = usePopoverPlacement(triggerRef, containerRef);
2330
- return (jsx("div", { className: "w-fit", ref: triggerRef, children: jsx(MoreMenu, { className: className, customButton: jsx(IconButton, { "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, title: label, variant: "secondary" }), customPortalId: isFullScreen ? customPortalId : undefined, "data-testid": dataTestId, popoverProps: { placement: popoverPlacement }, children: jsx(MenuContent, { availableSpace: availableSpace, items: items, presentation: "popover" }) }) }));
2328
+ return (jsx("div", { className: className ?? "w-fit", ref: triggerRef, children: jsxs(Popover, { placement: popoverPlacement, children: [jsx(PopoverTrigger, { children: jsx(IconButton, { "data-testid": dataTestId, icon: jsx(Icon, { name: resolved.icon ?? "QuestionMarkCircle", size: "small" }), size: resolved.size, title: label, variant: "secondary" }) }), jsx(PopoverContent, { className: "overflow-clip", portalId: isFullScreen ? customPortalId : undefined, children: jsx(MenuContent, { availableSpace: availableSpace, customPortalId: customPortalId, isFullScreen: isFullScreen, items: items }) })] }) }));
2331
2329
  };
2332
- const MenuPopover = ({ availableSpace, config, containerRef, customPortalId, overlaySide = undefined, resolved, }) => {
2333
- if (overlaySide === "right") {
2334
- return (jsx(MenuRightPopover, { availableSpace: availableSpace, config: config, customPortalId: customPortalId, resolved: resolved }));
2335
- }
2336
- return (jsx(MenuAutoPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, resolved: resolved }));
2337
- };
2338
- /** Modal presentation -- renders a Modal with no popover-specific hooks. */
2339
- const MenuModal = ({ availableSpace, config, resolved }) => (jsx(Modal, { config: config, resolved: resolved, children: jsx(MenuContent, { availableSpace: availableSpace, items: config.items, presentation: "modal" }) }));
2340
2330
  /**
2341
- * Renders a menu control as either a popover or a modal,
2342
- * depending on the `menuPresentation` prop derived from container size.
2331
+ * Renders a menu control as a popover -- either anchored to the trigger (auto-placed) or
2332
+ * opening to a fixed right placement, depending on `overlaySide`.
2343
2333
  *
2344
- * Delegates to `MenuPopover` or `MenuModal` so each path
2345
- * only runs the hooks it needs.
2334
+ * Delegates to sub-components so each path only runs the hooks it needs.
2346
2335
  *
2347
2336
  * @internal
2348
2337
  */
2349
- const Menu = ({ availableSpace, config, containerRef, customPortalId, menuPresentation, overlaySide = undefined, resolved, }) => {
2350
- if (menuPresentation === "modal") {
2351
- return jsx(MenuModal, { availableSpace: availableSpace, config: config, resolved: resolved });
2338
+ const Menu = ({ availableSpace, config, containerRef, customPortalId, overlaySide = undefined, resolved, }) => {
2339
+ if (overlaySide === "right") {
2340
+ return (jsx(MenuRightPopover, { availableSpace: availableSpace, config: config, customPortalId: customPortalId, resolved: resolved }));
2352
2341
  }
2353
- return (jsx(MenuPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, overlaySide: overlaySide, resolved: resolved }));
2342
+ return (jsx(MenuAutoPopover, { availableSpace: availableSpace, config: config, containerRef: containerRef, customPortalId: customPortalId, resolved: resolved }));
2354
2343
  };
2355
2344
 
2356
2345
  const fieldConfigForStandalonePopover = (config) => {
@@ -2481,7 +2470,7 @@ const ToggleGroup = ({ config, overlaySide: _overlaySide = undefined, resolved }
2481
2470
  *
2482
2471
  * @internal
2483
2472
  */
2484
- const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId, menuPresentation, overlaySide = undefined, }) => {
2473
+ const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId, overlaySide = undefined, }) => {
2485
2474
  const resolved = resolveControl(control, { placement: "standalone", availableSpace });
2486
2475
  switch (control.type) {
2487
2476
  case "button":
@@ -2500,7 +2489,7 @@ const ControlRenderer = ({ availableSpace, containerRef, control, customPortalId
2500
2489
  case "toggleGroup":
2501
2490
  return jsx(ToggleGroup, { config: control, overlaySide: overlaySide, resolved: resolved });
2502
2491
  case "menu":
2503
- return (jsx(Menu, { availableSpace: availableSpace, config: control, containerRef: containerRef, customPortalId: customPortalId, menuPresentation: menuPresentation, overlaySide: overlaySide, resolved: resolved }));
2492
+ return (jsx(Menu, { availableSpace: availableSpace, config: control, containerRef: containerRef, customPortalId: customPortalId, overlaySide: overlaySide, resolved: resolved }));
2504
2493
  case "custom":
2505
2494
  return jsx(Custom, { config: control, overlaySide: overlaySide, placement: "standalone", resolved: resolved });
2506
2495
  default: {
@@ -2542,8 +2531,6 @@ const ControlsContent = ({ api, controls: baseControlsConfig, className }) => {
2542
2531
  const measuredResponsiveMode = deriveResponsiveMode(breakpoints.isSm, breakpoints.isMd, containerHeight);
2543
2532
  const availableSpace = safeAreaLayout?.availableSpace ?? measuredAvailableSpace;
2544
2533
  const responsiveMode = safeAreaLayout?.responsiveMode ?? measuredResponsiveMode;
2545
- // Menus use modals only in collapsed mode (narrowest containers); popovers otherwise
2546
- const menuPresentation = responsiveMode === "collapsed" ? "modal" : "popover";
2547
2534
  // Derive portal ID from the map container's stable ID (set by createMapComponent)
2548
2535
  const portalId = api.containerRef.current?.id;
2549
2536
  // Geolocation position for region-appropriate globe icon (updated if the user clicks "My Location")
@@ -2579,7 +2566,7 @@ const ControlsContent = ({ api, controls: baseControlsConfig, className }) => {
2579
2566
  // Responsive grid: navigation bottom-right, settings top-left, tools top-right, statistics bottom-left
2580
2567
  "grid-cols-[1fr_auto] grid-rows-[auto_1fr_auto]",
2581
2568
  // Isolate creates a new stacking context to avoid z-index issues
2582
- "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasNavigationControls ? (jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1 self-end justify-self-end", children: navigation.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasSettingsControls ? (jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation, overlaySide: "right" }, control.id))) })) : null, hasToolsControls ? (jsx("div", { className: "pointer-events-auto col-start-2 row-start-1 flex flex-col gap-1 justify-self-end", "data-testid": "map-controls-tools", children: tools.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxs("div", { className: twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, menuPresentation: menuPresentation }, control.id))) })) : null] })) : null] }));
2569
+ "isolate", className), "data-testid": "map-controls", ref: mergedRef, children: [hasNavigationControls ? (jsx("div", { className: "pointer-events-auto col-start-2 row-start-3 flex flex-col gap-1 self-end justify-self-end", children: navigation.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null, hasSettingsControls ? (jsx("div", { className: "pointer-events-auto col-start-1 row-start-1 flex w-fit flex-col gap-1", "data-testid": "map-controls-settings", children: settings.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId, overlaySide: "right" }, control.id))) })) : null, hasToolsControls ? (jsx("div", { className: "pointer-events-auto col-start-2 row-start-1 flex flex-col gap-1 justify-self-end", "data-testid": "map-controls-tools", children: tools.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null, hasStatisticsAreaContent ? (jsxs("div", { className: twMerge("pointer-events-none col-start-1 row-start-3 flex max-h-full max-w-[20rem] flex-col items-start justify-end gap-1 overflow-visible", !hasStatisticsControls && "self-end"), "data-testid": "map-controls-statistics", children: [shouldRenderAnnotations ? (jsx("div", { className: "pointer-events-auto", "data-testid": "map-controls-statistics-annotations", children: jsx(AnnotationStack, { annotations: displayAnnotations, isExiting: isExiting }) })) : null, hasStatisticsControls ? (jsx("div", { className: "pointer-events-auto flex max-h-full w-fit max-w-full flex-col gap-1 overflow-auto", "data-testid": "map-controls-statistics-controls", children: statistics.controls.map(control => (jsx(ControlRenderer, { availableSpace: availableSpace, containerRef: containerRef, control: control, customPortalId: portalId }, control.id))) })) : null] })) : null] }));
2583
2570
  };
2584
2571
  /**
2585
2572
  * Map controls renderer — lays out a `ControlsConfig` across the four semantic
@@ -2685,7 +2672,7 @@ const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMa
2685
2672
  }
2686
2673
  return configs;
2687
2674
  }, [adapterConfig, options]);
2688
- return (jsx("div", { className: "flex items-start gap-2 overflow-x-auto px-3 py-2", role: "radiogroup", children: options.map(option => {
2675
+ return (jsx("div", { className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2689
2676
  const derivedConfig = derivedConfigs.get(option.theme);
2690
2677
  if (derivedConfig === undefined) {
2691
2678
  return null;
package/package.json CHANGED
@@ -1,23 +1,22 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": "2.2.0",
11
- "@trackunit/css-class-variance-utilities": "1.13.59",
12
- "@trackunit/react-form-components": "2.2.1",
13
- "@trackunit/react-core-hooks": "1.18.0",
14
- "@trackunit/geo-json-utils": "1.14.62",
15
- "@trackunit/i18n-library-translation": "2.1.0",
16
- "@trackunit/react-modal": "2.2.1",
10
+ "@trackunit/react-components": "2.2.1",
11
+ "@trackunit/css-class-variance-utilities": "1.13.60",
12
+ "@trackunit/react-form-components": "2.2.2",
13
+ "@trackunit/react-core-hooks": "1.18.1",
14
+ "@trackunit/geo-json-utils": "1.14.63",
15
+ "@trackunit/i18n-library-translation": "2.1.1",
17
16
  "react-minimal-pie-chart": "^8.4.0",
18
- "@trackunit/react-map-adapter-shared": "0.0.43",
19
- "@trackunit/react-map-color-utils": "0.0.28",
20
- "@trackunit/ui-design-tokens": "1.13.58",
17
+ "@trackunit/react-map-adapter-shared": "0.0.44",
18
+ "@trackunit/react-map-color-utils": "0.0.29",
19
+ "@trackunit/ui-design-tokens": "1.13.59",
21
20
  "@floating-ui/react": "^0.26.25",
22
21
  "es-toolkit": "^1.39.10",
23
22
  "tailwind-merge": "^2.0.0",
@@ -1,14 +1,13 @@
1
1
  import type { ComponentRef, RefObject } from "react";
2
2
  import type { ControlOverlaySide } from "./controlOverlaySide";
3
3
  import { type AvailableSpace } from "./renderingRules";
4
- import type { ControlConfig, MenuPresentation } from "./types";
4
+ import type { ControlConfig } from "./types";
5
5
  type ControlRendererProps = Readonly<{
6
6
  availableSpace: AvailableSpace;
7
7
  /** Ref to the controls overlay container, used to compute popover placement */
8
8
  containerRef: RefObject<ComponentRef<"div"> | null>;
9
9
  control: ControlConfig;
10
10
  customPortalId?: string;
11
- menuPresentation: MenuPresentation;
12
11
  overlaySide?: ControlOverlaySide;
13
12
  }>;
14
13
  /**
@@ -18,5 +17,5 @@ type ControlRendererProps = Readonly<{
18
17
  *
19
18
  * @internal
20
19
  */
21
- export declare const ControlRenderer: ({ availableSpace, containerRef, control, customPortalId, menuPresentation, overlaySide, }: ControlRendererProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const ControlRenderer: ({ availableSpace, containerRef, control, customPortalId, overlaySide, }: ControlRendererProps) => import("react/jsx-runtime").JSX.Element;
22
21
  export {};
@@ -1,25 +1,23 @@
1
1
  import { type ComponentRef, type RefObject } from "react";
2
2
  import { type ControlOverlaySide } from "../../controlOverlaySide";
3
3
  import { type AvailableSpace, type ResolvedControlProps } from "../../renderingRules";
4
- import type { MenuControlConfig, MenuPresentation } from "../../types";
4
+ import type { MenuControlConfig } from "../../types";
5
5
  type MenuProps = Readonly<{
6
6
  availableSpace: AvailableSpace;
7
7
  config: MenuControlConfig;
8
8
  /** Ref to the controls overlay container, used to compute popover placement */
9
9
  containerRef: RefObject<ComponentRef<"div"> | null>;
10
10
  customPortalId?: string;
11
- menuPresentation: MenuPresentation;
12
11
  overlaySide?: ControlOverlaySide;
13
12
  resolved: ResolvedControlProps;
14
13
  }>;
15
14
  /**
16
- * Renders a menu control as either a popover or a modal,
17
- * depending on the `menuPresentation` prop derived from container size.
15
+ * Renders a menu control as a popover -- either anchored to the trigger (auto-placed) or
16
+ * opening to a fixed right placement, depending on `overlaySide`.
18
17
  *
19
- * Delegates to `MenuPopover` or `MenuModal` so each path
20
- * only runs the hooks it needs.
18
+ * Delegates to sub-components so each path only runs the hooks it needs.
21
19
  *
22
20
  * @internal
23
21
  */
24
- export declare const Menu: ({ availableSpace, config, containerRef, customPortalId, menuPresentation, overlaySide, resolved, }: MenuProps) => import("react/jsx-runtime").JSX.Element;
22
+ export declare const Menu: ({ availableSpace, config, containerRef, customPortalId, overlaySide, resolved, }: MenuProps) => import("react/jsx-runtime").JSX.Element;
25
23
  export {};
@@ -2,6 +2,8 @@ import { type ResolvedControlProps } from "../../renderingRules";
2
2
  import type { SelectControlConfig } from "../../types";
3
3
  type MenuSelectProps = Readonly<{
4
4
  config: SelectControlConfig;
5
+ customPortalId?: string;
6
+ isFullScreen: boolean;
5
7
  resolved: ResolvedControlProps;
6
8
  }>;
7
9
  /**
@@ -9,7 +11,19 @@ type MenuSelectProps = Readonly<{
9
11
  * Purely presentational -- all rendering decisions come from resolved props.
10
12
  * `data-testid` (when set) is on the menu-item wrapper only — not duplicated on the inner `SelectField`.
11
13
  *
14
+ * The dropdown portals outside the menu's clipped/scrolled chrome (`overflow-clip`/`overflow-y-auto`
15
+ * on `PopoverContent`/`MenuContent`) so long option lists are never truncated -- same default
16
+ * behavior as the standalone toolbar `Select`. In fullscreen mode it portals into the map's own
17
+ * `customPortalId` element instead of `document.body`, since real `document.body` content is
18
+ * invisible while the map container is in the Fullscreen API (mirrors `PopoverContent`'s
19
+ * `portalId={isFullScreen ? customPortalId : undefined}` pattern).
20
+ *
21
+ * The dropdown portal always stacks at the `z-popover` layer (`SelectField`'s default), the same
22
+ * layer as the menu's own `PopoverContent` -- so it reliably paints on top rather than underneath
23
+ * the menu wherever their bounds overlap, since both portal near the top of the DOM tree outside
24
+ * the menu's own clipped subtree.
25
+ *
12
26
  * @internal
13
27
  */
14
- export declare const MenuSelect: ({ config, resolved: _resolved }: MenuSelectProps) => import("react/jsx-runtime").JSX.Element;
28
+ export declare const MenuSelect: ({ config, customPortalId, isFullScreen, resolved: _resolved }: MenuSelectProps) => import("react/jsx-runtime").JSX.Element;
15
29
  export {};
@@ -350,17 +350,6 @@ export interface BuiltInControlsConfig extends ControlsConfig {
350
350
  readonly navigation: BuiltInNavigationConfig;
351
351
  readonly settings: BuiltInSettingsConfig;
352
352
  }
353
- /**
354
- * How a menu presents its content.
355
- *
356
- * - `"popover"` — floating `MoreMenu` popover anchored to the trigger button.
357
- * - `"modal"` — bottom `Sheet` dialog (`variant="modal"`) with a backdrop.
358
- *
359
- * `Controls` derives this from `availableSpace`: `"constrained"` containers get
360
- * `"modal"` (Sheet bottom-sheet, better on small screens); `"comfortable"` containers
361
- * get `"popover"` (floating popover, better when there is room).
362
- */
363
- export type MenuPresentation = "popover" | "modal";
364
353
  /**
365
354
  * How aggressively controls are collapsed based on available space.
366
355
  *
@@ -2,4 +2,11 @@ import type { AvailableSpace, ResponsiveMode } from "../controls/types";
2
2
  export declare const SafeAreaLayoutContext: import("react").Context<Readonly<{
3
3
  availableSpace: AvailableSpace;
4
4
  responsiveMode: ResponsiveMode;
5
+ /**
6
+ * The inner content node of `SafeAreaOverlay` — the `relative` grid div whose border box
7
+ * equals the padded usable area. Sheet portals that should respect adapter safe-area insets
8
+ * (e.g. the Controls menu Sheet) should use this as their `container` prop so they do not
9
+ * paint over the attribution bar.
10
+ */
11
+ safeAreaContentEl: HTMLDivElement | null;
5
12
  }> | null>;
@@ -1 +0,0 @@
1
- {"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
@@ -1,21 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import type { ResolvedControlProps } from "../../renderingRules";
3
- import type { MenuControlConfig } from "../../types";
4
- type ModalProps = Readonly<{
5
- config: MenuControlConfig;
6
- resolved: ResolvedControlProps;
7
- /** The menu content (grouped sections and items) to render inside the modal body */
8
- children: ReactNode;
9
- }>;
10
- /**
11
- * Renders a menu control as a modal instead of a popover.
12
- * Used on narrow containers where popovers would be clipped or awkward.
13
- *
14
- * The trigger is the same IconButton used by the popover path.
15
- * The content is rendered inside a Modal with ModalHeader (label + close)
16
- * and ModalBody (scrollable menu items).
17
- *
18
- * @internal
19
- */
20
- export declare const Modal: ({ config, resolved, children }: ModalProps) => import("react/jsx-runtime").JSX.Element;
21
- export {};