@trackunit/react-map 0.2.33-alpha-288640a1f26.0 → 0.2.36

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.
Files changed (39) hide show
  1. package/index.cjs.js +129 -43
  2. package/index.esm.js +129 -43
  3. package/migrations/entry.js.map +1 -0
  4. package/package.json +10 -10
  5. package/src/appearance/AppearancePreviewGrid.d.ts +3 -1
  6. package/src/controls/collapseControls.d.ts +6 -4
  7. package/src/controls/types.d.ts +9 -2
  8. /package/{translation10.cjs.js → translation.cjs10.js} +0 -0
  9. /package/{translation11.cjs.js → translation.cjs11.js} +0 -0
  10. /package/{translation12.cjs.js → translation.cjs12.js} +0 -0
  11. /package/{translation13.cjs.js → translation.cjs13.js} +0 -0
  12. /package/{translation14.cjs.js → translation.cjs14.js} +0 -0
  13. /package/{translation15.cjs.js → translation.cjs15.js} +0 -0
  14. /package/{translation16.cjs.js → translation.cjs16.js} +0 -0
  15. /package/{translation17.cjs.js → translation.cjs17.js} +0 -0
  16. /package/{translation2.cjs.js → translation.cjs2.js} +0 -0
  17. /package/{translation3.cjs.js → translation.cjs3.js} +0 -0
  18. /package/{translation4.cjs.js → translation.cjs4.js} +0 -0
  19. /package/{translation5.cjs.js → translation.cjs5.js} +0 -0
  20. /package/{translation6.cjs.js → translation.cjs6.js} +0 -0
  21. /package/{translation7.cjs.js → translation.cjs7.js} +0 -0
  22. /package/{translation8.cjs.js → translation.cjs8.js} +0 -0
  23. /package/{translation9.cjs.js → translation.cjs9.js} +0 -0
  24. /package/{translation10.esm.js → translation.esm10.js} +0 -0
  25. /package/{translation11.esm.js → translation.esm11.js} +0 -0
  26. /package/{translation12.esm.js → translation.esm12.js} +0 -0
  27. /package/{translation13.esm.js → translation.esm13.js} +0 -0
  28. /package/{translation14.esm.js → translation.esm14.js} +0 -0
  29. /package/{translation15.esm.js → translation.esm15.js} +0 -0
  30. /package/{translation16.esm.js → translation.esm16.js} +0 -0
  31. /package/{translation17.esm.js → translation.esm17.js} +0 -0
  32. /package/{translation2.esm.js → translation.esm2.js} +0 -0
  33. /package/{translation3.esm.js → translation.esm3.js} +0 -0
  34. /package/{translation4.esm.js → translation.esm4.js} +0 -0
  35. /package/{translation5.esm.js → translation.esm5.js} +0 -0
  36. /package/{translation6.esm.js → translation.esm6.js} +0 -0
  37. /package/{translation7.esm.js → translation.esm7.js} +0 -0
  38. /package/{translation8.esm.js → translation.esm8.js} +0 -0
  39. /package/{translation9.esm.js → translation.esm9.js} +0 -0
package/index.cjs.js CHANGED
@@ -59,22 +59,22 @@ const translations = {
59
59
  default: defaultTranslations,
60
60
  languages: {
61
61
  de: () => Promise.resolve().then(function () { return require('./translation.cjs.js'); }),
62
- da: () => Promise.resolve().then(function () { return require('./translation2.cjs.js'); }),
63
- cs: () => Promise.resolve().then(function () { return require('./translation3.cjs.js'); }),
64
- nl: () => Promise.resolve().then(function () { return require('./translation4.cjs.js'); }),
65
- fr: () => Promise.resolve().then(function () { return require('./translation5.cjs.js'); }),
66
- fi: () => Promise.resolve().then(function () { return require('./translation6.cjs.js'); }),
67
- hu: () => Promise.resolve().then(function () { return require('./translation7.cjs.js'); }),
68
- it: () => Promise.resolve().then(function () { return require('./translation8.cjs.js'); }),
69
- nb: () => Promise.resolve().then(function () { return require('./translation9.cjs.js'); }),
70
- pl: () => Promise.resolve().then(function () { return require('./translation10.cjs.js'); }),
71
- pt: () => Promise.resolve().then(function () { return require('./translation11.cjs.js'); }),
72
- ru: () => Promise.resolve().then(function () { return require('./translation12.cjs.js'); }),
73
- ro: () => Promise.resolve().then(function () { return require('./translation13.cjs.js'); }),
74
- es: () => Promise.resolve().then(function () { return require('./translation14.cjs.js'); }),
75
- sv: () => Promise.resolve().then(function () { return require('./translation15.cjs.js'); }),
76
- ja: () => Promise.resolve().then(function () { return require('./translation16.cjs.js'); }),
77
- th: () => Promise.resolve().then(function () { return require('./translation17.cjs.js'); }),
62
+ da: () => Promise.resolve().then(function () { return require('./translation.cjs2.js'); }),
63
+ cs: () => Promise.resolve().then(function () { return require('./translation.cjs3.js'); }),
64
+ nl: () => Promise.resolve().then(function () { return require('./translation.cjs4.js'); }),
65
+ fr: () => Promise.resolve().then(function () { return require('./translation.cjs5.js'); }),
66
+ fi: () => Promise.resolve().then(function () { return require('./translation.cjs6.js'); }),
67
+ hu: () => Promise.resolve().then(function () { return require('./translation.cjs7.js'); }),
68
+ it: () => Promise.resolve().then(function () { return require('./translation.cjs8.js'); }),
69
+ nb: () => Promise.resolve().then(function () { return require('./translation.cjs9.js'); }),
70
+ pl: () => Promise.resolve().then(function () { return require('./translation.cjs10.js'); }),
71
+ pt: () => Promise.resolve().then(function () { return require('./translation.cjs11.js'); }),
72
+ ru: () => Promise.resolve().then(function () { return require('./translation.cjs12.js'); }),
73
+ ro: () => Promise.resolve().then(function () { return require('./translation.cjs13.js'); }),
74
+ es: () => Promise.resolve().then(function () { return require('./translation.cjs14.js'); }),
75
+ sv: () => Promise.resolve().then(function () { return require('./translation.cjs15.js'); }),
76
+ ja: () => Promise.resolve().then(function () { return require('./translation.cjs16.js'); }),
77
+ th: () => Promise.resolve().then(function () { return require('./translation.cjs17.js'); }),
78
78
  },
79
79
  };
80
80
  /**
@@ -1579,14 +1579,43 @@ const usePreviewMap = (adapterConfig, options) => {
1579
1579
  return react.useMemo(() => ({ Map, ready }), [Map, ready]);
1580
1580
  };
1581
1581
 
1582
+ /** Shared separator between control/category blocks. */
1583
+ const SEPARATOR = { type: "separator" };
1582
1584
  // ============================================================================
1583
1585
  // Overflow Behavior Helper
1584
1586
  // ============================================================================
1585
1587
  /** Resolve a control's overflow behavior, defaulting to "menu". */
1586
1588
  const resolveOverflow = (control) => control.overflowBehavior ?? "menu";
1587
- /** Filter out controls that should be hidden (not menued) when collapsing. */
1589
+ /** Filter out controls that opted out of menuing with `overflowBehavior: "hide"`. */
1588
1590
  const excludeHidden = (controls) => controls.filter(c => resolveOverflow(c) !== "hide");
1589
1591
  // ============================================================================
1592
+ // Section name as a Fallback Header
1593
+ // ============================================================================
1594
+ /**
1595
+ * Fallback `section` across the block's leading run of items that declare none of their
1596
+ * own -- from the first non-separator item up to the first separator or the first item
1597
+ * with its own `section` (ADR 0027).
1598
+ *
1599
+ * The whole run gets it so the header and the items it visually claims land in one
1600
+ * `groupItemsBySection` group. The run stops at a separator because a separator always
1601
+ * starts a new group, so carrying the section past one would repeat the header.
1602
+ */
1603
+ const applyFallbackSectionAsHeader = (items, section) => {
1604
+ const runStart = items.findIndex(item => item.type !== "separator");
1605
+ const firstItem = items[runStart];
1606
+ if (firstItem === undefined || firstItem.type === "separator" || firstItem.section !== undefined)
1607
+ return items;
1608
+ const runEndOffset = items
1609
+ .slice(runStart + 1)
1610
+ .findIndex(item => item.type === "separator" || item.section !== undefined);
1611
+ const runEnd = runEndOffset === -1 ? items.length : runStart + 1 + runEndOffset;
1612
+ return items.map((item, index) => {
1613
+ if (index < runStart || index >= runEnd || item.type === "separator")
1614
+ return item;
1615
+ return { ...item, section };
1616
+ });
1617
+ };
1618
+ // ============================================================================
1590
1619
  // Control-to-MenuItem Conversion
1591
1620
  // ============================================================================
1592
1621
  /**
@@ -1596,10 +1625,10 @@ const excludeHidden = (controls) => controls.filter(c => resolveOverflow(c) !==
1596
1625
  * One special case:
1597
1626
  * - **Menu** → flattens its items into the parent (menus cannot nest)
1598
1627
  *
1599
- * When `section` is provided, it is set on each output item (except separators
1600
- * which don't support sections).
1628
+ * Items keep whatever `section` their author declared. The category label is
1629
+ * applied later, once per block, by {@link applyFallbackSectionAsHeader}.
1601
1630
  */
1602
- const controlToMenuItems = (control, section) => {
1631
+ const controlToMenuItems = (control) => {
1603
1632
  switch (control.type) {
1604
1633
  case "button":
1605
1634
  case "toggle":
@@ -1610,14 +1639,10 @@ const controlToMenuItems = (control, section) => {
1610
1639
  case "search":
1611
1640
  case "custom":
1612
1641
  case "stepper":
1613
- return [section !== undefined ? { ...control, section } : control];
1642
+ return [control];
1614
1643
  case "menu":
1615
- // Flatten the menu's items into the parent, applying section to each
1616
- return control.items.map(item => {
1617
- if (item.type === "separator")
1618
- return item;
1619
- return section !== undefined ? { ...item, section } : item;
1620
- });
1644
+ // Menus cannot nest -- flatten the menu's items into the parent
1645
+ return control.items;
1621
1646
  default: {
1622
1647
  const exhaustiveCheck = control;
1623
1648
  throw new Error(`Unknown control type: ${exhaustiveCheck}`);
@@ -1625,6 +1650,60 @@ const controlToMenuItems = (control, section) => {
1625
1650
  }
1626
1651
  };
1627
1652
  // ============================================================================
1653
+ // Flattening a Set of Controls Into One Menu
1654
+ // ============================================================================
1655
+ /**
1656
+ * Trim edge separators and collapse runs (so authored separators do not double up
1657
+ * against delimiters from {@link flattenControlsToMenuItems}).
1658
+ */
1659
+ const normalizeSeparators = (items) => {
1660
+ const normalized = items.reduce((accumulated, item) => {
1661
+ if (item.type !== "separator")
1662
+ return [...accumulated, item];
1663
+ if (accumulated.length === 0 || accumulated.at(-1)?.type === "separator")
1664
+ return accumulated;
1665
+ return [...accumulated, SEPARATOR];
1666
+ }, []);
1667
+ return normalized.at(-1)?.type === "separator" ? normalized.slice(0, -1) : normalized;
1668
+ };
1669
+ /**
1670
+ * Whether a control type renders as one row inside a menu: two adjacent single-row
1671
+ * controls skip the separator between them, anything else is delimited (ADR 0027).
1672
+ *
1673
+ * Total `Record` so a new `ControlConfig` type fails compile until classified.
1674
+ */
1675
+ const RENDERS_AS_SINGLE_ROW = {
1676
+ button: true,
1677
+ checkbox: true,
1678
+ stepper: true,
1679
+ toggle: true,
1680
+ custom: false,
1681
+ menu: false,
1682
+ radioGroup: false,
1683
+ search: false,
1684
+ select: false,
1685
+ toggleGroup: false,
1686
+ };
1687
+ const isSingleRowControl = (control) => RENDERS_AS_SINGLE_ROW[control.type];
1688
+ /**
1689
+ * Flatten controls into one menu's items, preserving declared order (ADR 0022).
1690
+ *
1691
+ * Separator between adjacent controls unless both render as a single row
1692
+ * ({@link RENDERS_AS_SINGLE_ROW}). Empty/separator-only controls contribute no
1693
+ * control block (ADR 0027).
1694
+ */
1695
+ const flattenControlsToMenuItems = (controls) => {
1696
+ const controlBlocks = controls
1697
+ .map(control => ({ isSingleRow: isSingleRowControl(control), items: controlToMenuItems(control) }))
1698
+ .filter(controlBlock => controlBlock.items.some(item => item.type !== "separator"));
1699
+ const flattened = controlBlocks.flatMap((controlBlock, index) => {
1700
+ const previousBlock = controlBlocks[index - 1];
1701
+ const needsSeparator = previousBlock !== undefined && !(previousBlock.isSingleRow && controlBlock.isSingleRow);
1702
+ return needsSeparator ? [SEPARATOR, ...controlBlock.items] : controlBlock.items;
1703
+ });
1704
+ return normalizeSeparators(flattened);
1705
+ };
1706
+ // ============================================================================
1628
1707
  // Category Collapsing (categorized mode)
1629
1708
  // ============================================================================
1630
1709
  /**
@@ -1640,7 +1719,7 @@ const collapseCategory = (controls, categoryKey, meta) => {
1640
1719
  return controls;
1641
1720
  // Exclude controls that prefer hiding over being menued
1642
1721
  const menuable = excludeHidden(controls);
1643
- const menuItems = menuable.flatMap(control => controlToMenuItems(control));
1722
+ const menuItems = flattenControlsToMenuItems(menuable);
1644
1723
  // If nothing remains after filtering, return empty
1645
1724
  if (menuItems.length === 0)
1646
1725
  return [];
@@ -1657,21 +1736,17 @@ const collapseCategory = (controls, categoryKey, meta) => {
1657
1736
  return [menu];
1658
1737
  };
1659
1738
  /**
1660
- * Collects all controls from all categories into a single menu with
1661
- * named sections per category.
1739
+ * Merge all categories into one menu. Non-empty category blocks are
1740
+ * separator-delimited; labels via {@link applyFallbackSectionAsHeader}.
1662
1741
  *
1663
1742
  * Controls with `overflowBehavior: "hide"` are excluded and fully hidden.
1664
1743
  */
1665
1744
  const collapseAll = (categories, allControls) => {
1666
- const allItems = [];
1667
- for (const { label, controls } of categories) {
1668
- if (controls.length === 0)
1669
- continue;
1745
+ const categoryBlocks = categories
1670
1746
  // Exclude controls that prefer hiding over being menued
1671
- const menuable = excludeHidden(controls);
1672
- const items = menuable.flatMap(control => controlToMenuItems(control, label));
1673
- allItems.push(...items);
1674
- }
1747
+ .map(({ label, controls }) => applyFallbackSectionAsHeader(flattenControlsToMenuItems(excludeHidden(controls)), label))
1748
+ .filter(items => items.length > 0);
1749
+ const allItems = categoryBlocks.flatMap((items, index) => (index === 0 ? items : [SEPARATOR, ...items]));
1675
1750
  // Nothing to show
1676
1751
  if (allItems.length === 0) {
1677
1752
  return {
@@ -1706,7 +1781,9 @@ const collapseAll = (categories, allControls) => {
1706
1781
  * - `"full"` -- pass through unchanged
1707
1782
  * - `"categorized"` -- group controls within each category into a per-category
1708
1783
  * menu (only when >1 control in that category)
1709
- * - `"collapsed"` -- merge all controls into a single menu with named sections
1784
+ * - `"collapsed"` -- merge all controls into a single menu, separator-delimited per
1785
+ * category. Items keep their own `section`; the category name only heads a block whose
1786
+ * leading items declare none (ADR 0027)
1710
1787
  *
1711
1788
  * This operates at the config level, before controls enter the middleware
1712
1789
  * rendering pipeline. Existing renderers handle the resulting menus with no
@@ -2291,6 +2368,9 @@ const renderMenuItem = (item, index, availableSpace, customPortalId, isFullScree
2291
2368
  const groupItemsBySection = (items) => {
2292
2369
  const groups = [];
2293
2370
  for (const item of items) {
2371
+ // Separators get their own group so they delimit rather than join one: a separator declares no
2372
+ // `section`, so without this branch its `undefined` matches the preceding sectionless group and
2373
+ // `[a, separator, b]` would collapse from three groups into one holding all three.
2294
2374
  if (item.type === "separator") {
2295
2375
  groups.push({ section: undefined, items: [item] });
2296
2376
  continue;
@@ -2440,7 +2520,7 @@ const Stepper = ({ config, overlaySide = undefined, resolved }) => {
2440
2520
  : decrementHover.hovering
2441
2521
  ? `${label}, ${decreaseLabel}`
2442
2522
  : label;
2443
- return (jsxRuntime.jsx(reactComponents.Tooltip, { delayed: true, label: tooltipLabel, placement: overlaySide === "right" ? RIGHT_CONTROL_TOOLTIP_PLACEMENT : undefined, children: jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("grid", className), "data-testid": dataTestId, children: [jsxRuntime.jsx(reactComponents.IconButton, { "aria-label": increaseLabel, className: "rounded-b-none", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Plus", size: "small" }), onClick: onClickIncrement, onMouseEnter: incrementHover.onMouseEnter, onMouseLeave: incrementHover.onMouseLeave, size: size, title: increaseLabel, variant: "secondary" }), jsxRuntime.jsx(reactComponents.IconButton, { "aria-label": decreaseLabel, className: "translate-y-[-1px] rounded-t-none", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Minus", size: "small" }), onClick: onClickDecrement, onMouseEnter: decrementHover.onMouseEnter, onMouseLeave: decrementHover.onMouseLeave, size: size, title: decreaseLabel, variant: "secondary" })] }) }));
2523
+ return (jsxRuntime.jsx(reactComponents.Tooltip, { delayed: true, label: tooltipLabel, placement: overlaySide === "right" ? RIGHT_CONTROL_TOOLTIP_PLACEMENT : undefined, children: jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("grid", className), "data-testid": dataTestId, children: [jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: increaseLabel, className: "rounded-b-none", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Plus", size: "small" }), onClick: onClickIncrement, onMouseEnter: incrementHover.onMouseEnter, onMouseLeave: incrementHover.onMouseLeave, size: size, variant: "secondary" }), jsxRuntime.jsx(reactComponents.IconButton, { ariaLabel: decreaseLabel, className: "translate-y-[-1px] rounded-t-none", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Minus", size: "small" }), onClick: onClickDecrement, onMouseEnter: decrementHover.onMouseEnter, onMouseLeave: decrementHover.onMouseLeave, size: size, variant: "secondary" })] }) }));
2444
2524
  };
2445
2525
 
2446
2526
  function assertStandaloneToggleGroupIcons(config) {
@@ -2667,7 +2747,7 @@ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect,
2667
2747
  *
2668
2748
  * @internal
2669
2749
  */
2670
- const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMapType, onChange, }) => {
2750
+ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }) => {
2671
2751
  const derivedConfigs = react.useMemo(() => {
2672
2752
  const configs = new Map();
2673
2753
  for (const option of options) {
@@ -2677,7 +2757,7 @@ const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMa
2677
2757
  }
2678
2758
  return configs;
2679
2759
  }, [adapterConfig, options]);
2680
- return (jsxRuntime.jsx("div", { className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2760
+ return (jsxRuntime.jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2681
2761
  const derivedConfig = derivedConfigs.get(option.theme);
2682
2762
  if (derivedConfig === undefined) {
2683
2763
  return null;
@@ -2872,7 +2952,13 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2872
2952
  type: "custom",
2873
2953
  id: "map-style-preview",
2874
2954
  label: t("settings.mapStyle.title"),
2875
- render: () => (jsxRuntime.jsx(AppearancePreviewGrid, { adapterConfig: adapterConfig, currentMapType: localState.mapType, currentTheme: localState.theme, onChange: handleAppearanceChange, options: previewOptions })),
2955
+ // Heads the grid in every mode. In a flattened responsive menu the trigger label is
2956
+ // gone, so without this the grid renders anonymously under the previous control's
2957
+ // items; in "full" mode it makes this menu consistent with Assets and Sites.
2958
+ section: t("settings.mapStyle.title"),
2959
+ render: () => (jsxRuntime.jsx(AppearancePreviewGrid, { adapterConfig: adapterConfig,
2960
+ // `SectionHeader` is role="presentation", so the header above names nothing.
2961
+ ariaLabel: t("settings.mapStyle.title"), currentMapType: localState.mapType, currentTheme: localState.theme, onChange: handleAppearanceChange, options: previewOptions })),
2876
2962
  },
2877
2963
  ];
2878
2964
  const isSatelliteOrHybrid = localState.mapType === "satellite" || localState.mapType === "hybrid";
package/index.esm.js CHANGED
@@ -58,22 +58,22 @@ const translations = {
58
58
  default: defaultTranslations,
59
59
  languages: {
60
60
  de: () => import('./translation.esm.js'),
61
- da: () => import('./translation2.esm.js'),
62
- cs: () => import('./translation3.esm.js'),
63
- nl: () => import('./translation4.esm.js'),
64
- fr: () => import('./translation5.esm.js'),
65
- fi: () => import('./translation6.esm.js'),
66
- hu: () => import('./translation7.esm.js'),
67
- it: () => import('./translation8.esm.js'),
68
- nb: () => import('./translation9.esm.js'),
69
- pl: () => import('./translation10.esm.js'),
70
- pt: () => import('./translation11.esm.js'),
71
- ru: () => import('./translation12.esm.js'),
72
- ro: () => import('./translation13.esm.js'),
73
- es: () => import('./translation14.esm.js'),
74
- sv: () => import('./translation15.esm.js'),
75
- ja: () => import('./translation16.esm.js'),
76
- th: () => import('./translation17.esm.js'),
61
+ da: () => import('./translation.esm2.js'),
62
+ cs: () => import('./translation.esm3.js'),
63
+ nl: () => import('./translation.esm4.js'),
64
+ fr: () => import('./translation.esm5.js'),
65
+ fi: () => import('./translation.esm6.js'),
66
+ hu: () => import('./translation.esm7.js'),
67
+ it: () => import('./translation.esm8.js'),
68
+ nb: () => import('./translation.esm9.js'),
69
+ pl: () => import('./translation.esm10.js'),
70
+ pt: () => import('./translation.esm11.js'),
71
+ ru: () => import('./translation.esm12.js'),
72
+ ro: () => import('./translation.esm13.js'),
73
+ es: () => import('./translation.esm14.js'),
74
+ sv: () => import('./translation.esm15.js'),
75
+ ja: () => import('./translation.esm16.js'),
76
+ th: () => import('./translation.esm17.js'),
77
77
  },
78
78
  };
79
79
  /**
@@ -1578,14 +1578,43 @@ const usePreviewMap = (adapterConfig, options) => {
1578
1578
  return useMemo(() => ({ Map, ready }), [Map, ready]);
1579
1579
  };
1580
1580
 
1581
+ /** Shared separator between control/category blocks. */
1582
+ const SEPARATOR = { type: "separator" };
1581
1583
  // ============================================================================
1582
1584
  // Overflow Behavior Helper
1583
1585
  // ============================================================================
1584
1586
  /** Resolve a control's overflow behavior, defaulting to "menu". */
1585
1587
  const resolveOverflow = (control) => control.overflowBehavior ?? "menu";
1586
- /** Filter out controls that should be hidden (not menued) when collapsing. */
1588
+ /** Filter out controls that opted out of menuing with `overflowBehavior: "hide"`. */
1587
1589
  const excludeHidden = (controls) => controls.filter(c => resolveOverflow(c) !== "hide");
1588
1590
  // ============================================================================
1591
+ // Section name as a Fallback Header
1592
+ // ============================================================================
1593
+ /**
1594
+ * Fallback `section` across the block's leading run of items that declare none of their
1595
+ * own -- from the first non-separator item up to the first separator or the first item
1596
+ * with its own `section` (ADR 0027).
1597
+ *
1598
+ * The whole run gets it so the header and the items it visually claims land in one
1599
+ * `groupItemsBySection` group. The run stops at a separator because a separator always
1600
+ * starts a new group, so carrying the section past one would repeat the header.
1601
+ */
1602
+ const applyFallbackSectionAsHeader = (items, section) => {
1603
+ const runStart = items.findIndex(item => item.type !== "separator");
1604
+ const firstItem = items[runStart];
1605
+ if (firstItem === undefined || firstItem.type === "separator" || firstItem.section !== undefined)
1606
+ return items;
1607
+ const runEndOffset = items
1608
+ .slice(runStart + 1)
1609
+ .findIndex(item => item.type === "separator" || item.section !== undefined);
1610
+ const runEnd = runEndOffset === -1 ? items.length : runStart + 1 + runEndOffset;
1611
+ return items.map((item, index) => {
1612
+ if (index < runStart || index >= runEnd || item.type === "separator")
1613
+ return item;
1614
+ return { ...item, section };
1615
+ });
1616
+ };
1617
+ // ============================================================================
1589
1618
  // Control-to-MenuItem Conversion
1590
1619
  // ============================================================================
1591
1620
  /**
@@ -1595,10 +1624,10 @@ const excludeHidden = (controls) => controls.filter(c => resolveOverflow(c) !==
1595
1624
  * One special case:
1596
1625
  * - **Menu** → flattens its items into the parent (menus cannot nest)
1597
1626
  *
1598
- * When `section` is provided, it is set on each output item (except separators
1599
- * which don't support sections).
1627
+ * Items keep whatever `section` their author declared. The category label is
1628
+ * applied later, once per block, by {@link applyFallbackSectionAsHeader}.
1600
1629
  */
1601
- const controlToMenuItems = (control, section) => {
1630
+ const controlToMenuItems = (control) => {
1602
1631
  switch (control.type) {
1603
1632
  case "button":
1604
1633
  case "toggle":
@@ -1609,14 +1638,10 @@ const controlToMenuItems = (control, section) => {
1609
1638
  case "search":
1610
1639
  case "custom":
1611
1640
  case "stepper":
1612
- return [section !== undefined ? { ...control, section } : control];
1641
+ return [control];
1613
1642
  case "menu":
1614
- // Flatten the menu's items into the parent, applying section to each
1615
- return control.items.map(item => {
1616
- if (item.type === "separator")
1617
- return item;
1618
- return section !== undefined ? { ...item, section } : item;
1619
- });
1643
+ // Menus cannot nest -- flatten the menu's items into the parent
1644
+ return control.items;
1620
1645
  default: {
1621
1646
  const exhaustiveCheck = control;
1622
1647
  throw new Error(`Unknown control type: ${exhaustiveCheck}`);
@@ -1624,6 +1649,60 @@ const controlToMenuItems = (control, section) => {
1624
1649
  }
1625
1650
  };
1626
1651
  // ============================================================================
1652
+ // Flattening a Set of Controls Into One Menu
1653
+ // ============================================================================
1654
+ /**
1655
+ * Trim edge separators and collapse runs (so authored separators do not double up
1656
+ * against delimiters from {@link flattenControlsToMenuItems}).
1657
+ */
1658
+ const normalizeSeparators = (items) => {
1659
+ const normalized = items.reduce((accumulated, item) => {
1660
+ if (item.type !== "separator")
1661
+ return [...accumulated, item];
1662
+ if (accumulated.length === 0 || accumulated.at(-1)?.type === "separator")
1663
+ return accumulated;
1664
+ return [...accumulated, SEPARATOR];
1665
+ }, []);
1666
+ return normalized.at(-1)?.type === "separator" ? normalized.slice(0, -1) : normalized;
1667
+ };
1668
+ /**
1669
+ * Whether a control type renders as one row inside a menu: two adjacent single-row
1670
+ * controls skip the separator between them, anything else is delimited (ADR 0027).
1671
+ *
1672
+ * Total `Record` so a new `ControlConfig` type fails compile until classified.
1673
+ */
1674
+ const RENDERS_AS_SINGLE_ROW = {
1675
+ button: true,
1676
+ checkbox: true,
1677
+ stepper: true,
1678
+ toggle: true,
1679
+ custom: false,
1680
+ menu: false,
1681
+ radioGroup: false,
1682
+ search: false,
1683
+ select: false,
1684
+ toggleGroup: false,
1685
+ };
1686
+ const isSingleRowControl = (control) => RENDERS_AS_SINGLE_ROW[control.type];
1687
+ /**
1688
+ * Flatten controls into one menu's items, preserving declared order (ADR 0022).
1689
+ *
1690
+ * Separator between adjacent controls unless both render as a single row
1691
+ * ({@link RENDERS_AS_SINGLE_ROW}). Empty/separator-only controls contribute no
1692
+ * control block (ADR 0027).
1693
+ */
1694
+ const flattenControlsToMenuItems = (controls) => {
1695
+ const controlBlocks = controls
1696
+ .map(control => ({ isSingleRow: isSingleRowControl(control), items: controlToMenuItems(control) }))
1697
+ .filter(controlBlock => controlBlock.items.some(item => item.type !== "separator"));
1698
+ const flattened = controlBlocks.flatMap((controlBlock, index) => {
1699
+ const previousBlock = controlBlocks[index - 1];
1700
+ const needsSeparator = previousBlock !== undefined && !(previousBlock.isSingleRow && controlBlock.isSingleRow);
1701
+ return needsSeparator ? [SEPARATOR, ...controlBlock.items] : controlBlock.items;
1702
+ });
1703
+ return normalizeSeparators(flattened);
1704
+ };
1705
+ // ============================================================================
1627
1706
  // Category Collapsing (categorized mode)
1628
1707
  // ============================================================================
1629
1708
  /**
@@ -1639,7 +1718,7 @@ const collapseCategory = (controls, categoryKey, meta) => {
1639
1718
  return controls;
1640
1719
  // Exclude controls that prefer hiding over being menued
1641
1720
  const menuable = excludeHidden(controls);
1642
- const menuItems = menuable.flatMap(control => controlToMenuItems(control));
1721
+ const menuItems = flattenControlsToMenuItems(menuable);
1643
1722
  // If nothing remains after filtering, return empty
1644
1723
  if (menuItems.length === 0)
1645
1724
  return [];
@@ -1656,21 +1735,17 @@ const collapseCategory = (controls, categoryKey, meta) => {
1656
1735
  return [menu];
1657
1736
  };
1658
1737
  /**
1659
- * Collects all controls from all categories into a single menu with
1660
- * named sections per category.
1738
+ * Merge all categories into one menu. Non-empty category blocks are
1739
+ * separator-delimited; labels via {@link applyFallbackSectionAsHeader}.
1661
1740
  *
1662
1741
  * Controls with `overflowBehavior: "hide"` are excluded and fully hidden.
1663
1742
  */
1664
1743
  const collapseAll = (categories, allControls) => {
1665
- const allItems = [];
1666
- for (const { label, controls } of categories) {
1667
- if (controls.length === 0)
1668
- continue;
1744
+ const categoryBlocks = categories
1669
1745
  // Exclude controls that prefer hiding over being menued
1670
- const menuable = excludeHidden(controls);
1671
- const items = menuable.flatMap(control => controlToMenuItems(control, label));
1672
- allItems.push(...items);
1673
- }
1746
+ .map(({ label, controls }) => applyFallbackSectionAsHeader(flattenControlsToMenuItems(excludeHidden(controls)), label))
1747
+ .filter(items => items.length > 0);
1748
+ const allItems = categoryBlocks.flatMap((items, index) => (index === 0 ? items : [SEPARATOR, ...items]));
1674
1749
  // Nothing to show
1675
1750
  if (allItems.length === 0) {
1676
1751
  return {
@@ -1705,7 +1780,9 @@ const collapseAll = (categories, allControls) => {
1705
1780
  * - `"full"` -- pass through unchanged
1706
1781
  * - `"categorized"` -- group controls within each category into a per-category
1707
1782
  * menu (only when >1 control in that category)
1708
- * - `"collapsed"` -- merge all controls into a single menu with named sections
1783
+ * - `"collapsed"` -- merge all controls into a single menu, separator-delimited per
1784
+ * category. Items keep their own `section`; the category name only heads a block whose
1785
+ * leading items declare none (ADR 0027)
1709
1786
  *
1710
1787
  * This operates at the config level, before controls enter the middleware
1711
1788
  * rendering pipeline. Existing renderers handle the resulting menus with no
@@ -2290,6 +2367,9 @@ const renderMenuItem = (item, index, availableSpace, customPortalId, isFullScree
2290
2367
  const groupItemsBySection = (items) => {
2291
2368
  const groups = [];
2292
2369
  for (const item of items) {
2370
+ // Separators get their own group so they delimit rather than join one: a separator declares no
2371
+ // `section`, so without this branch its `undefined` matches the preceding sectionless group and
2372
+ // `[a, separator, b]` would collapse from three groups into one holding all three.
2293
2373
  if (item.type === "separator") {
2294
2374
  groups.push({ section: undefined, items: [item] });
2295
2375
  continue;
@@ -2439,7 +2519,7 @@ const Stepper = ({ config, overlaySide = undefined, resolved }) => {
2439
2519
  : decrementHover.hovering
2440
2520
  ? `${label}, ${decreaseLabel}`
2441
2521
  : label;
2442
- return (jsx(Tooltip, { delayed: true, label: tooltipLabel, placement: overlaySide === "right" ? RIGHT_CONTROL_TOOLTIP_PLACEMENT : undefined, children: jsxs("div", { className: twMerge("grid", className), "data-testid": dataTestId, children: [jsx(IconButton, { "aria-label": increaseLabel, className: "rounded-b-none", icon: jsx(Icon, { name: "Plus", size: "small" }), onClick: onClickIncrement, onMouseEnter: incrementHover.onMouseEnter, onMouseLeave: incrementHover.onMouseLeave, size: size, title: increaseLabel, variant: "secondary" }), jsx(IconButton, { "aria-label": decreaseLabel, className: "translate-y-[-1px] rounded-t-none", icon: jsx(Icon, { name: "Minus", size: "small" }), onClick: onClickDecrement, onMouseEnter: decrementHover.onMouseEnter, onMouseLeave: decrementHover.onMouseLeave, size: size, title: decreaseLabel, variant: "secondary" })] }) }));
2522
+ return (jsx(Tooltip, { delayed: true, label: tooltipLabel, placement: overlaySide === "right" ? RIGHT_CONTROL_TOOLTIP_PLACEMENT : undefined, children: jsxs("div", { className: twMerge("grid", className), "data-testid": dataTestId, children: [jsx(IconButton, { ariaLabel: increaseLabel, className: "rounded-b-none", icon: jsx(Icon, { name: "Plus", size: "small" }), onClick: onClickIncrement, onMouseEnter: incrementHover.onMouseEnter, onMouseLeave: incrementHover.onMouseLeave, size: size, variant: "secondary" }), jsx(IconButton, { ariaLabel: decreaseLabel, className: "translate-y-[-1px] rounded-t-none", icon: jsx(Icon, { name: "Minus", size: "small" }), onClick: onClickDecrement, onMouseEnter: decrementHover.onMouseEnter, onMouseLeave: decrementHover.onMouseLeave, size: size, variant: "secondary" })] }) }));
2443
2523
  };
2444
2524
 
2445
2525
  function assertStandaloneToggleGroupIcons(config) {
@@ -2666,7 +2746,7 @@ const AppearancePreview = ({ adapterConfig, mapType, label, selected, onSelect,
2666
2746
  *
2667
2747
  * @internal
2668
2748
  */
2669
- const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMapType, onChange, }) => {
2749
+ const AppearancePreviewGrid = ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }) => {
2670
2750
  const derivedConfigs = useMemo(() => {
2671
2751
  const configs = new Map();
2672
2752
  for (const option of options) {
@@ -2676,7 +2756,7 @@ const AppearancePreviewGrid = ({ adapterConfig, options, currentTheme, currentMa
2676
2756
  }
2677
2757
  return configs;
2678
2758
  }, [adapterConfig, options]);
2679
- return (jsx("div", { className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2759
+ return (jsx("div", { "aria-label": ariaLabel, className: "flex flex-wrap items-start gap-2 px-3 py-2", role: "radiogroup", children: options.map(option => {
2680
2760
  const derivedConfig = derivedConfigs.get(option.theme);
2681
2761
  if (derivedConfig === undefined) {
2682
2762
  return null;
@@ -2871,7 +2951,13 @@ const useMapAppearanceControls = ({ api, options: appearanceOptions, persistence
2871
2951
  type: "custom",
2872
2952
  id: "map-style-preview",
2873
2953
  label: t("settings.mapStyle.title"),
2874
- render: () => (jsx(AppearancePreviewGrid, { adapterConfig: adapterConfig, currentMapType: localState.mapType, currentTheme: localState.theme, onChange: handleAppearanceChange, options: previewOptions })),
2954
+ // Heads the grid in every mode. In a flattened responsive menu the trigger label is
2955
+ // gone, so without this the grid renders anonymously under the previous control's
2956
+ // items; in "full" mode it makes this menu consistent with Assets and Sites.
2957
+ section: t("settings.mapStyle.title"),
2958
+ render: () => (jsx(AppearancePreviewGrid, { adapterConfig: adapterConfig,
2959
+ // `SectionHeader` is role="presentation", so the header above names nothing.
2960
+ ariaLabel: t("settings.mapStyle.title"), currentMapType: localState.mapType, currentTheme: localState.theme, onChange: handleAppearanceChange, options: previewOptions })),
2875
2961
  },
2876
2962
  ];
2877
2963
  const isSatelliteOrHybrid = localState.mapType === "satellite" || localState.mapType === "hybrid";
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/map/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@trackunit/react-map",
3
- "version": "0.2.33-alpha-288640a1f26.0",
3
+ "version": "0.2.36",
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.26-alpha-288640a1f26.0",
11
- "@trackunit/css-class-variance-utilities": "1.13.83-alpha-288640a1f26.0",
12
- "@trackunit/react-form-components": "2.2.27-alpha-288640a1f26.0",
13
- "@trackunit/react-core-hooks": "1.18.24-alpha-288640a1f26.0",
14
- "@trackunit/geo-json-utils": "1.14.86-alpha-288640a1f26.0",
15
- "@trackunit/i18n-library-translation": "2.1.24-alpha-288640a1f26.0",
10
+ "@trackunit/react-components": "2.2.26",
11
+ "@trackunit/css-class-variance-utilities": "1.13.82",
12
+ "@trackunit/react-form-components": "2.2.28",
13
+ "@trackunit/react-core-hooks": "1.18.23",
14
+ "@trackunit/geo-json-utils": "1.14.85",
15
+ "@trackunit/i18n-library-translation": "2.1.23",
16
16
  "react-minimal-pie-chart": "^8.4.0",
17
- "@trackunit/react-map-adapter-shared": "0.0.67-alpha-288640a1f26.0",
18
- "@trackunit/react-map-color-utils": "0.0.52-alpha-288640a1f26.0",
19
- "@trackunit/ui-design-tokens": "1.13.82-alpha-288640a1f26.0",
17
+ "@trackunit/react-map-adapter-shared": "0.0.66",
18
+ "@trackunit/react-map-color-utils": "0.0.51",
19
+ "@trackunit/ui-design-tokens": "1.13.81",
20
20
  "@floating-ui/react": "^0.26.25",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "tailwind-merge": "^2.0.0",
@@ -2,6 +2,8 @@ import type { AdapterConfig } from "@trackunit/react-map-adapter-shared";
2
2
  import type { MapTheme, MapType } from "../core/types";
3
3
  import type { AppearanceOption } from "./appearanceTypes";
4
4
  type AppearancePreviewGridProps = Readonly<{
5
+ /** Accessible name for the radiogroup (the visual `SectionHeader` is `role="presentation"`) */
6
+ ariaLabel: string;
5
7
  /** The adapter config from the main map (used to derive preview configs) */
6
8
  adapterConfig: AdapterConfig;
7
9
  /** Appearance options to display */
@@ -25,5 +27,5 @@ type AppearancePreviewGridProps = Readonly<{
25
27
  *
26
28
  * @internal
27
29
  */
28
- export declare const AppearancePreviewGrid: ({ adapterConfig, options, currentTheme, currentMapType, onChange, }: AppearancePreviewGridProps) => import("react/jsx-runtime").JSX.Element;
30
+ export declare const AppearancePreviewGrid: ({ adapterConfig, ariaLabel, options, currentTheme, currentMapType, onChange, }: AppearancePreviewGridProps) => import("react/jsx-runtime").JSX.Element;
29
31
  export {};
@@ -16,10 +16,10 @@ export type CategoryMetadata = Readonly<Record<CategoryKey, CategoryMeta> & {
16
16
  * One special case:
17
17
  * - **Menu** → flattens its items into the parent (menus cannot nest)
18
18
  *
19
- * When `section` is provided, it is set on each output item (except separators
20
- * which don't support sections).
19
+ * Items keep whatever `section` their author declared. The category label is
20
+ * applied later, once per block, by {@link applyFallbackSectionAsHeader}.
21
21
  */
22
- export declare const controlToMenuItems: (control: ControlConfig, section?: string) => ReadonlyArray<MenuItemConfig>;
22
+ export declare const controlToMenuItems: (control: ControlConfig) => ReadonlyArray<MenuItemConfig>;
23
23
  /**
24
24
  * Pure config transform that restructures `ControlsConfig` based on how much
25
25
  * space is available.
@@ -27,7 +27,9 @@ export declare const controlToMenuItems: (control: ControlConfig, section?: stri
27
27
  * - `"full"` -- pass through unchanged
28
28
  * - `"categorized"` -- group controls within each category into a per-category
29
29
  * menu (only when >1 control in that category)
30
- * - `"collapsed"` -- merge all controls into a single menu with named sections
30
+ * - `"collapsed"` -- merge all controls into a single menu, separator-delimited per
31
+ * category. Items keep their own `section`; the category name only heads a block whose
32
+ * leading items declare none (ADR 0027)
31
33
  *
32
34
  * This operates at the config level, before controls enter the middleware
33
35
  * rendering pipeline. Existing renderers handle the resulting menus with no
@@ -284,6 +284,11 @@ export type CustomControlConfig = BaseControlConfig<"custom"> & Readonly<{
284
284
  * Use this to transform or extend the resolved props based on the environment.
285
285
  * For example, override size decisions or add custom visibility logic.
286
286
  *
287
+ * **Caveat —** returning `visible: false` hides the control at render time only.
288
+ * Responsive flattening, its separators and its section headers are computed
289
+ * beforehand and do not account for it, so a control hidden this way inside an
290
+ * overflow menu can leave an orphaned divider or section header behind.
291
+ *
287
292
  * @example
288
293
  * ```ts
289
294
  * resolve: (resolved, renderContext) => ({
@@ -356,8 +361,10 @@ export interface BuiltInControlsConfig extends ControlsConfig {
356
361
  * - `"full"` -- all standalone controls shown as-is (wide containers)
357
362
  * - `"categorized"` -- controls within each category grouped into a per-category
358
363
  * menu when >1 control exists in that category (medium containers)
359
- * - `"collapsed"` -- all controls collapsed into a single menu button with
360
- * named sections per category (narrow containers)
364
+ * - `"collapsed"` -- all controls collapsed into a single menu button, with each
365
+ * category's block separated from the next (narrow containers). Items keep whatever
366
+ * `section` they declare; the category name is only used as a fallback header for a
367
+ * block whose leading items declare none.
361
368
  */
362
369
  export type ResponsiveMode = "full" | "categorized" | "collapsed";
363
370
  /** The four semantic control areas used for categorization and section headers */
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes