@defra/interactive-map 0.0.49-alpha → 0.0.50-alpha

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/interactive-map",
3
- "version": "0.0.49-alpha",
3
+ "version": "0.0.50-alpha",
4
4
  "description": "An accessible map component",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1 +1 @@
1
- .im-c-search-form{border-radius:0;display:none;filter:var(--search-drop-shadow);min-height:auto;padding:0}.im-c-search-form:before{border-radius:0;box-shadow:none}.im-o-app--mobile .im-c-search-form{margin:calc(var(--primary-gap)*-1) calc(var(--primary-gap)*-1) 0;overflow:visible;position:relative}.im-o-app--desktop .im-c-search-form,.im-o-app--tablet .im-c-search-form{border-radius:var(--button-border-radius);box-shadow:none;overflow:visible;position:relative}.im-o-app--desktop .im-c-search-form:before,.im-o-app--tablet .im-c-search-form:before{border-radius:var(--button-border-radius)}.im-o-app--desktop .im-c-search-form .im-c-search-suggestions,.im-o-app--tablet .im-c-search-form .im-c-search-suggestions{left:0;position:absolute;right:0;top:calc(100% + 2px);z-index:1}.im-c-search__status{background-color:var(--background-color);border-bottom-left-radius:var(--button-border-radius);border-bottom-right-radius:var(--button-border-radius);border-top:1px solid var(--button-hover-color);left:0;padding:var(--divider-gap) calc(var(--divider-gap) + 2px);position:absolute;right:0;top:100%}.im-o-app--mobile .im-c-search-form--default-expanded .im-c-search-suggestions{left:0;position:absolute;top:100%;width:100%}.im-c-search-close-button{position:relative}.im-c-search-close-button:before{box-shadow:none}.im-c-search-close-button:hover,.im-c-search-close-button[data-focus-visible=true]{z-index:1}.im-c-search-submit-button{position:relative}.im-c-search-submit-button:before{box-shadow:none}.im-c-search-submit-button[data-focus-visible=true]{z-index:1}.im-c-search-suggestions{background-color:var(--background-color);list-style:none;margin:0;max-height:calc(100vh - 66px);overflow-y:auto;padding:0;scroll-padding:0}.im-c-search-suggestions__item{border-top:1px solid var(--button-hover-color);color:var(--secondary-text-color);cursor:pointer;margin:0;padding:calc(var(--divider-gap) + 2px) var(--primary-gap);position:relative;user-select:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item{color:Highlight}}.im-c-search-suggestions__item mark{background-color:transparent;color:var(--foreground-color);font-weight:700;pointer-events:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item mark{color:Highlight}}@media (hover:hover) and (pointer:fine){.im-c-search-suggestions__item:hover:not([aria-selected=true]){background-color:var(--button-hover-color)}}.im-c-search-suggestions__item[aria-selected=true]{background-color:var(--button-hover-color)}.im-c-search-suggestions__item[aria-selected=true] mark{color:var(--fixed-foreground-color)}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item[aria-selected=true] mark{color:Highlight}}.im-c-search-suggestions__item:hover:not([aria-selected=true]) .im-c-search-suggestions__label{text-decoration:underline;text-decoration-color:var(--foreground-color);text-decoration-skip-ink:none;text-decoration-thickness:2px;text-underline-offset:2px}.im-c-search-suggestions__item[aria-selected=true] .im-c-search-suggestions__label{background-color:var(--focus-outline-color);box-shadow:0 -2px var(--focus-outline-color),0 4px var(--focus-border-color);color:var(--focus-border-color);outline:3px solid transparent;text-decoration:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item[aria-selected=true] .im-c-search-suggestions__label{color:Highlight;outline-color:Highlight}}.im-c-search-suggestions__label{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.im-o-app--mobile .im-c-search-form--default-expanded .im-c-search-suggestions{box-shadow:var(--panel-box-shadow);clip-path:inset(0 0 -20px 0)}.im-o-app--desktop .im-c-search-suggestions,.im-o-app--tablet .im-c-search-suggestions{border-bottom-left-radius:var(--button-border-radius);border-bottom-right-radius:var(--button-border-radius)}.im-o-app--desktop .im-c-search-suggestions__item,.im-o-app--tablet .im-c-search-suggestions__item{padding:var(--divider-gap) calc(var(--divider-gap) + 2px)}:root{--search-drop-shadow:drop-shadow(0 -2px 20px rgba(0,0,0,.1)) drop-shadow(0 0 12px rgba(0,0,0,.1))}.im-c-search{display:flow-root}.im-c-search--collapsed{display:none}.im-o-app__header:has(>.im-c-search--collapsed:only-child){margin-bottom:0}.im-c-search__input-container{display:flex;flex:1 1 auto;height:var(--button-size);min-height:auto;position:relative}.im-c-search__input-container:before{border:2px solid var(--foreground-color);border-radius:var(--button-border-radius);bottom:-2px;content:"";left:-2px;pointer-events:none;position:absolute;right:-2px;top:-2px;z-index:1}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container:before{border-color:ButtonText}}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container:hover:not(:has(.im-c-search-close-button:hover)):before{border-color:Highlight}}.im-c-search__input-container:after{border:var(--focus-border-width) solid var(--focus-border-color);border-radius:calc(var(--button-border-radius) - 2px);bottom:-1px;content:"";left:-1px;opacity:0;outline:var(--focus-box-shadow-width) solid var(--focus-outline-color);pointer-events:none;position:absolute;right:-1px;top:-1px;z-index:99}@media (prefers-reduced-motion:no-preference){.im-c-search__input-container:after{transition:opacity var(--duration) ease}}.im-o-app .im-c-search__input{background-color:transparent;border:0;color:var(--foreground-color);font-size:1rem;outline:0;padding:var(--divider-gap) calc(var(--divider-gap) + 2px);width:100%}.im-o-app .im-c-search__input:focus{box-shadow:none;outline:3px solid transparent}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-o-app .im-c-search__input:focus{outline:none}}.im-o-app .im-c-search__input::placeholder{color:var(--foreground-color)}.im-o-app .im-c-search__input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;display:none}.im-o-app .im-c-search__input::-moz-search-clear{display:none}.im-c-search__hint{display:none}.im-o-app--exclusive-control .im-o-app__right .im-c-button-wrapper,.im-o-app--exclusive-control .im-o-app__top .im-c-button-wrapper{opacity:0;z-index:-1}.im-o-app--exclusive-control .im-c-button-wrapper--search{pointer-events:none;position:absolute}.im-o-app--exclusive-control .im-o-app__left-bottom .im-c-panel,.im-o-app--exclusive-control .im-o-app__left-top .im-c-panel,.im-o-app--exclusive-control .im-o-app__right-bottom .im-c-panel,.im-o-app--exclusive-control .im-o-app__right-top .im-c-panel{opacity:0;z-index:-1}.im-o-app--exclusive-control .im-o-app__banner{opacity:0;z-index:-1}.im-o-app--mobile .im-c-search__input-container{margin:var(--primary-gap)}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container--keyboard-focus-within:before{border-color:Highlight}}.im-c-search__input-container--keyboard-focus-within:after{opacity:1}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container--keyboard-focus-within:after{opacity:0}.im-c-search__input-container--keyboard-focus-within{outline:var(--focus-highlight-width) solid Highlight}}
1
+ .im-c-search-form{border-radius:0;display:none;filter:var(--search-drop-shadow);min-height:auto;padding:0}.im-c-search-form:before{border-radius:0;box-shadow:none}.im-o-app--mobile .im-c-search-form{margin:calc(var(--primary-gap)*-1) calc(var(--primary-gap)*-1) 0;overflow:visible;position:relative}.im-o-app--desktop .im-c-search-form,.im-o-app--tablet .im-c-search-form{border-radius:var(--button-border-radius);box-shadow:none;overflow:visible;position:relative}.im-o-app--desktop .im-c-search-form:before,.im-o-app--tablet .im-c-search-form:before{border-radius:var(--button-border-radius)}.im-o-app--desktop .im-c-search-form .im-c-search-suggestions,.im-o-app--tablet .im-c-search-form .im-c-search-suggestions{left:0;position:absolute;right:0;top:calc(100% + 2px);z-index:1}.im-c-search__status{background-color:var(--background-color);border-bottom-left-radius:var(--button-border-radius);border-bottom-right-radius:var(--button-border-radius);border-top:1px solid var(--button-hover-color);left:0;padding:var(--divider-gap) calc(var(--divider-gap) + 2px);position:absolute;right:0;top:100%}.im-o-app--mobile .im-c-search-form--default-expanded .im-c-search-suggestions{left:0;position:absolute;top:100%;width:100%}.im-o-app--mobile .im-c-search-form:not(.im-c-search-form--default-expanded) .im-c-search-suggestions{left:0;position:absolute;top:100%;width:100%;z-index:1}.im-c-search-close-button{position:relative}.im-c-search-close-button:before{box-shadow:none}.im-c-search-close-button:hover,.im-c-search-close-button[data-focus-visible=true]{z-index:1}.im-c-search-submit-button{position:relative}.im-c-search-submit-button:before{box-shadow:none}.im-c-search-submit-button[data-focus-visible=true]{z-index:1}.im-c-search-suggestions{background-color:var(--background-color);list-style:none;margin:0;max-height:calc(100vh - 66px);overflow-y:auto;padding:0;scroll-padding:0}.im-c-search-suggestions__item{border-top:1px solid var(--button-hover-color);color:var(--secondary-text-color);cursor:pointer;margin:0;padding:calc(var(--divider-gap) + 2px) var(--primary-gap);position:relative;user-select:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item{color:Highlight}}.im-c-search-suggestions__item mark{background-color:transparent;color:var(--foreground-color);font-weight:700;pointer-events:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item mark{color:Highlight}}@media (hover:hover) and (pointer:fine){.im-c-search-suggestions__item:hover:not([aria-selected=true]){background-color:var(--button-hover-color)}}.im-c-search-suggestions__item[aria-selected=true]{background-color:var(--button-hover-color)}.im-c-search-suggestions__item[aria-selected=true] mark{color:var(--fixed-foreground-color)}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item[aria-selected=true] mark{color:Highlight}}.im-c-search-suggestions__item:hover:not([aria-selected=true]) .im-c-search-suggestions__label{text-decoration:underline;text-decoration-color:var(--foreground-color);text-decoration-skip-ink:none;text-decoration-thickness:2px;text-underline-offset:2px}.im-c-search-suggestions__item[aria-selected=true] .im-c-search-suggestions__label{background-color:var(--focus-outline-color);box-shadow:0 -2px var(--focus-outline-color),0 4px var(--focus-border-color);color:var(--focus-border-color);outline:3px solid transparent;text-decoration:none}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search-suggestions__item[aria-selected=true] .im-c-search-suggestions__label{color:Highlight;outline-color:Highlight}}.im-c-search-suggestions__label{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.im-o-app--mobile .im-c-search-form--default-expanded .im-c-search-suggestions{box-shadow:var(--panel-box-shadow);clip-path:inset(0 0 -20px 0)}.im-o-app--desktop .im-c-search-suggestions,.im-o-app--tablet .im-c-search-suggestions{border-bottom-left-radius:var(--button-border-radius);border-bottom-right-radius:var(--button-border-radius)}.im-o-app--desktop .im-c-search-suggestions__item,.im-o-app--tablet .im-c-search-suggestions__item{padding:var(--divider-gap) calc(var(--divider-gap) + 2px)}:root{--search-drop-shadow:drop-shadow(0 -2px 20px rgba(0,0,0,.1)) drop-shadow(0 0 12px rgba(0,0,0,.1))}.im-c-search{display:flow-root}.im-c-search--collapsed{display:none}.im-o-app__header:has(>.im-c-search--collapsed:only-child){margin-bottom:0}.im-c-search__input-container{display:flex;flex:1 1 auto;height:var(--button-size);min-height:auto;position:relative}.im-c-search__input-container:before{border:2px solid var(--foreground-color);border-radius:var(--button-border-radius);bottom:-2px;content:"";left:-2px;pointer-events:none;position:absolute;right:-2px;top:-2px;z-index:1}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container:before{border-color:ButtonText}}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container:hover:not(:has(.im-c-search-close-button:hover)):before{border-color:Highlight}}.im-c-search__input-container:after{border:var(--focus-border-width) solid var(--focus-border-color);border-radius:calc(var(--button-border-radius) - 2px);bottom:-1px;content:"";left:-1px;opacity:0;outline:var(--focus-box-shadow-width) solid var(--focus-outline-color);pointer-events:none;position:absolute;right:-1px;top:-1px;z-index:99}@media (prefers-reduced-motion:no-preference){.im-c-search__input-container:after{transition:opacity var(--duration) ease}}.im-o-app .im-c-search__input{background-color:transparent;border:0;color:var(--foreground-color);font-size:1rem;outline:0;padding:var(--divider-gap) calc(var(--divider-gap) + 2px);width:100%}.im-o-app .im-c-search__input:focus{box-shadow:none;outline:3px solid transparent}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-o-app .im-c-search__input:focus{outline:none}}.im-o-app .im-c-search__input::placeholder{color:var(--foreground-color)}.im-o-app .im-c-search__input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;display:none}.im-o-app .im-c-search__input::-moz-search-clear{display:none}.im-c-search__hint{display:none}.im-o-app--exclusive-control .im-o-app__right .im-c-button-wrapper,.im-o-app--exclusive-control .im-o-app__top .im-c-button-wrapper{opacity:0;z-index:-1}.im-o-app--exclusive-control .im-c-button-wrapper--search{pointer-events:none;position:absolute}.im-o-app--exclusive-control .im-o-app__left-bottom .im-c-panel,.im-o-app--exclusive-control .im-o-app__left-top .im-c-panel,.im-o-app--exclusive-control .im-o-app__right-bottom .im-c-panel,.im-o-app--exclusive-control .im-o-app__right-top .im-c-panel{opacity:0;z-index:-1}.im-o-app--exclusive-control .im-o-app__banner{opacity:0;z-index:-1}.im-o-app--mobile .im-c-search__input-container{margin:var(--primary-gap)}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container--keyboard-focus-within:before{border-color:Highlight}}.im-c-search__input-container--keyboard-focus-within:after{opacity:1}@media (-ms-high-contrast:active),screen and (forced-colors:active){.im-c-search__input-container--keyboard-focus-within:after{opacity:0}.im-c-search__input-container--keyboard-focus-within{outline:var(--focus-highlight-width) solid Highlight}}
@@ -57,4 +57,13 @@
57
57
  top: 100%;
58
58
  width: 100%;
59
59
  left: 0;
60
- }
60
+ }
61
+
62
+ // Tap-to-open mobile search reflows the header otherwise, inflating the safe zone while suggestions are open.
63
+ .im-o-app--mobile .im-c-search-form:not(.im-c-search-form--default-expanded) .im-c-search-suggestions {
64
+ position: absolute;
65
+ top: 100%;
66
+ width: 100%;
67
+ left: 0;
68
+ z-index: 1;
69
+ }
@@ -148,7 +148,7 @@ function applyAttributionsLayout ({ appContainer, bottom, attributions, dividerG
148
148
  * for `fitBounds`/`setView`. Waits for `arePluginsEvaluated` so the inset reflects final
149
149
  * button visibility rather than a mid-evaluation state, which would make the map jump.
150
150
  */
151
- function calculateLayout (layoutRefs, breakpoint) {
151
+ export function calculateLayout (layoutRefs, breakpoint) {
152
152
  const {
153
153
  appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
154
154
  bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
@@ -4,18 +4,26 @@ import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
5
5
  import { EVENTS as events } from '../../config/events.js'
6
6
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
7
+ import { calculateLayout } from './useLayoutMeasurements.js'
7
8
  import { scalePoints } from '../../utils/scalePoints.js'
8
9
  import { scaleFactor } from '../../config/appConfig.js'
9
10
 
10
11
  export const useMapProviderOverrides = () => {
11
12
  const { mapProvider, eventBus } = useConfig()
12
- const { dispatch: appDispatch, layoutRefs } = useApp()
13
+ const { dispatch: appDispatch, layoutRefs, breakpoint } = useApp()
13
14
  const { mapSize } = useMap()
14
15
 
15
16
  const latestMapSize = useRef(mapSize)
16
17
  latestMapSize.current = mapSize
18
+ const latestBreakpoint = useRef(breakpoint)
19
+ latestBreakpoint.current = breakpoint
17
20
 
18
21
  const updatePadding = () => {
22
+ // Refresh the derived layout CSS vars (e.g. --left-offset-top) before reading them —
23
+ // callers often trigger this right after a state change that resizes the header/columns
24
+ // (e.g. collapsing mobile search), and those vars are only otherwise refreshed by a
25
+ // separate ResizeObserver callback that isn't guaranteed to have run yet.
26
+ calculateLayout(layoutRefs, latestBreakpoint.current)
19
27
  const safeZoneInset = getSafeZoneInset(layoutRefs)
20
28
  const padding = scalePoints(safeZoneInset, scaleFactor[latestMapSize.current])
21
29
 
@@ -43,12 +51,18 @@ export const useMapProviderOverrides = () => {
43
51
  return undefined
44
52
  }
45
53
 
46
- // Calculate and set safe zone padding unless explicitly skipped
47
- if (!skipPaddingCalc) {
48
- updatePadding()
54
+ if (skipPaddingCalc) {
55
+ return originalFitToBounds.call(mapProvider, bounds)
49
56
  }
50
57
 
51
- return originalFitToBounds.call(mapProvider, bounds)
58
+ // Callers often dispatch layout-affecting state (e.g. collapsing the mobile search
59
+ // form) immediately before calling this, so measuring on the same tick can capture
60
+ // the DOM mid-transition. Wait a frame so React has painted first.
61
+ requestAnimationFrame(() => {
62
+ updatePadding()
63
+ originalFitToBounds.call(mapProvider, bounds)
64
+ })
65
+ return undefined
52
66
  }
53
67
 
54
68
  const originalSetView = mapProvider.setView
@@ -58,9 +72,11 @@ export const useMapProviderOverrides = () => {
58
72
  return undefined
59
73
  }
60
74
 
61
- updatePadding()
62
-
63
- return originalSetView.call(mapProvider, { center, zoom })
75
+ requestAnimationFrame(() => {
76
+ updatePadding()
77
+ originalSetView.call(mapProvider, { center, zoom })
78
+ })
79
+ return undefined
64
80
  }
65
81
 
66
82
  return () => {
@@ -4,6 +4,7 @@ import { useConfig } from '../store/configContext.js'
4
4
  import { useApp } from '../store/appContext.js'
5
5
  import { useMap } from '../store/mapContext.js'
6
6
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
7
+ import { calculateLayout } from './useLayoutMeasurements.js'
7
8
  import { scalePoints } from '../../utils/scalePoints.js'
8
9
  import { scaleFactor } from '../../config/appConfig.js'
9
10
 
@@ -11,6 +12,7 @@ jest.mock('../store/configContext.js')
11
12
  jest.mock('../store/appContext.js')
12
13
  jest.mock('../store/mapContext.js')
13
14
  jest.mock('../../utils/getSafeZoneInset.js')
15
+ jest.mock('./useLayoutMeasurements.js')
14
16
  jest.mock('../../utils/scalePoints.js')
15
17
 
16
18
  const setup = (overrides = {}) => {
@@ -30,7 +32,7 @@ const setup = (overrides = {}) => {
30
32
  }
31
33
 
32
34
  useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
33
- useApp.mockReturnValue({ dispatch, layoutRefs, ...overrides.app })
35
+ useApp.mockReturnValue({ dispatch, layoutRefs, breakpoint: 'mobile', ...overrides.app })
34
36
  useMap.mockReturnValue({ mapSize: 'md', ...overrides.map })
35
37
 
36
38
  getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
@@ -40,7 +42,13 @@ const setup = (overrides = {}) => {
40
42
  }
41
43
 
42
44
  describe('useMapProviderOverrides', () => {
43
- beforeEach(jest.clearAllMocks)
45
+ beforeEach(() => {
46
+ jest.clearAllMocks()
47
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => cb())
48
+ })
49
+ afterEach(() => {
50
+ global.requestAnimationFrame.mockRestore()
51
+ })
44
52
 
45
53
  test('early returns when mapProvider is null', () => {
46
54
  setup({ config: { mapProvider: null } })
@@ -57,6 +65,7 @@ describe('useMapProviderOverrides', () => {
57
65
 
58
66
  mapProvider.fitToBounds([0, 0, 1, 1])
59
67
 
68
+ expect(calculateLayout).toHaveBeenCalledWith(layoutRefs, 'mobile')
60
69
  expect(getSafeZoneInset).toHaveBeenCalledWith(layoutRefs)
61
70
  expect(scalePoints).toHaveBeenCalledWith(safeZone, scaleFactor.lg)
62
71
  expect(dispatch).toHaveBeenCalledWith({
@@ -6,22 +6,15 @@
6
6
  *
7
7
  * Each edge inset is driven by:
8
8
  * - A structural baseline from the button columns, footer, and action bar.
9
- * - A panel contribution when panels on that edge cover more than 1/RATIO
10
- * of the available map dimension along that edge.
9
+ * - A panel contribution once a panel (or the two panels stacked in a side's
10
+ * slots) covers more than 1/RATIO of the available map height (widening
11
+ * that side's left/right inset) and/or more than 1/RATIO of the available
12
+ * map width (pushing the top/bottom inset down/up). Both can trigger at
13
+ * once for a panel that's both tall and wide — independently, not
14
+ * exclusively, since the MAX_RATIO cap below already bounds each edge.
11
15
  * - A cap so no single inset exceeds 1/MAX_RATIO of the map dimension,
12
16
  * preventing fitBounds from zooming out to a corner when panels are large.
13
17
  *
14
- * Trigger logic per edge:
15
- * - left / right : combined HEIGHT of panels in that column vs available height
16
- * - top / bottom : WIDTH of panels in that row vs available width
17
- *
18
- * When a panel individually exceeds BOTH the height and width thresholds it
19
- * would otherwise trigger both adjacent edges, pushing the safe zone into the
20
- * opposite corner. To prevent this, such panels are classified as either
21
- * "column-primary" (h/availableH ≥ w/availableW) or "row-primary" and only
22
- * contribute to their dominant edge. Panels that exceed at most one threshold
23
- * are never in conflict and contribute to both axes freely.
24
- *
25
18
  * @param {Object} refs - React refs from layoutRefs.
26
19
  * @param {React.RefObject} refs.mainRef - Main map container.
27
20
  * @param {React.RefObject} refs.leftRef - Left button column.
@@ -36,7 +29,7 @@
36
29
  * Pixel insets from each edge of the main area, or undefined if any required ref is missing.
37
30
  */
38
31
 
39
- const RATIO = 2 // panels covering more than 1/RATIO of an edge trigger that edge's inset
32
+ const RATIO = 2 // panels covering more than 1/RATIO of the available height/width trigger that edge's inset
40
33
  const MAX_RATIO = 3 // each inset is capped at 1/MAX_RATIO of its map dimension
41
34
 
42
35
  // Query the panel element directly within its slot container.
@@ -61,50 +54,24 @@ const getButtonColWidth = (colRef) => {
61
54
  return group ? group.offsetWidth : 0
62
55
  }
63
56
 
64
- // Returns the panel's contribution to column (left/right) and row (top/bottom) edges.
65
- //
66
- // A panel can only trigger BOTH adjacent edges simultaneously when it individually
67
- // exceeds both the height threshold (h > availableH/RATIO) and the width threshold
68
- // (w > availableW/RATIO). In that case, only the dominant direction is used, preventing
69
- // a corner panel from pushing the safe zone into the opposite corner.
70
- // When only one (or neither) threshold is individually exceeded, the panel contributes
71
- // its dimensions to both axes freely (combined thresholds still apply later).
72
- const panelContrib = (w, h, availableW, availableH) => {
73
- const hFrac = h / availableH
74
- const wFrac = w / availableW
75
- if (hFrac >= 1 / RATIO && wFrac >= 1 / RATIO) {
76
- return hFrac >= wFrac
77
- ? { colH: h, colW: w, rowW: 0, rowH: 0 } // column-primary (left/right)
78
- : { colH: 0, colW: 0, rowW: w, rowH: h } // row-primary (top/bottom)
79
- }
80
- return { colH: h, colW: w, rowW: w, rowH: h }
81
- }
82
-
83
- // Determines the column (left/right) inset and the row-axis contributions for both panels.
84
- //
85
- // Column coverage uses raw heights so that two panels whose combined height exceeds the
86
- // threshold always trigger the column inset, even if one is individually row-primary.
87
- // Exception: a single row-primary panel (no sibling, colH=0) is suppressed so it
88
- // contributes to top/bottom instead — giving a larger safe zone in the horizontal direction.
89
- // When triggered, row contributions for both panels are zeroed to prevent double-padding.
90
- const computeColumn = (panelA, panelB, availableW, availableH, hThreshold, columnLeft, gap) => {
91
- const a = panelContrib(panelA.offsetWidth, panelA.offsetHeight, availableW, availableH)
92
- const b = panelContrib(panelB.offsetWidth, panelB.offsetHeight, availableW, availableH)
93
- const aSingleRowPrimary = panelB.offsetHeight === 0 && a.colH === 0
94
- const bSingleRowPrimary = panelA.offsetHeight === 0 && b.colH === 0
57
+ // Determines the column (left/right) inset from the two panels stacked in that side's
58
+ // slots. Combined height exceeding the threshold triggers the inset, widened to fit
59
+ // whichever panel is wider.
60
+ const computeColumn = (panelA, panelB, hThreshold, columnLeft, gap) => {
95
61
  const coverage = panelA.offsetHeight + panelB.offsetHeight + (panelA.offsetHeight > 0 && panelB.offsetHeight > 0 ? gap : 0)
96
- const triggered = coverage > hThreshold && !aSingleRowPrimary && !bSingleRowPrimary
97
- return {
98
- panelInset: triggered ? columnLeft + Math.max(panelA.offsetWidth, panelB.offsetWidth) + gap : 0,
99
- rowA: triggered ? { w: 0, h: 0 } : { w: a.rowW, h: a.rowH },
100
- rowB: triggered ? { w: 0, h: 0 } : { w: b.rowW, h: b.rowH }
101
- }
62
+ const triggered = coverage > hThreshold
63
+ return triggered ? columnLeft + Math.max(panelA.offsetWidth, panelB.offsetWidth) + gap : 0
102
64
  }
103
65
 
104
- // Determines the row-axis (top/bottom) panel inset driven by combined panel width.
105
- // The combined width test subsumes the individual checks (dimensions are non-negative).
106
- const computeRow = (leftW, rightW, leftH, rightH, wThreshold, baseInset, gap) =>
107
- leftW + rightW > wThreshold ? baseInset + Math.max(leftH, rightH) + gap : 0
66
+ // Determines the row (top/bottom) inset from the panels docked in the left and right
67
+ // columns' near slot (both top, or both bottom). Combined width exceeding the threshold
68
+ // triggers the inset, deepened to fit whichever panel is taller. Evaluated independently
69
+ // of computeColumn — a panel can widen its own column AND push top/bottom down at once.
70
+ const computeRow = (leftPanel, rightPanel, wThreshold, baseInset, gap) => {
71
+ const coverage = leftPanel.offsetWidth + rightPanel.offsetWidth + (leftPanel.offsetWidth > 0 && rightPanel.offsetWidth > 0 ? gap : 0)
72
+ const triggered = coverage > wThreshold
73
+ return triggered ? baseInset + Math.max(leftPanel.offsetHeight, rightPanel.offsetHeight) + gap : 0
74
+ }
108
75
 
109
76
  export const getSafeZoneInset = ({
110
77
  mainRef, leftRef, rightRef, actionsRef, bottomRef,
@@ -135,13 +102,10 @@ export const getSafeZoneInset = ({
135
102
  const availableH = main.offsetHeight - baseTop - baseBottom
136
103
  const availableW = main.offsetWidth - (baseLeft - main.offsetLeft) - baseRight
137
104
 
138
- const leftCol = computeColumn(rawTL, rawBL, availableW, availableH, availableH / RATIO, main.offsetLeft + left.offsetLeft, gap)
139
- const rightCol = computeColumn(rawTR, rawBR, availableW, availableH, availableH / RATIO, left.offsetLeft, gap)
140
-
141
- const leftPanelInset = leftCol.panelInset
142
- const rightPanelInset = rightCol.panelInset
143
- const topPanelInset = computeRow(leftCol.rowA.w, rightCol.rowA.w, leftCol.rowA.h, rightCol.rowA.h, availableW / RATIO, baseTop, gap)
144
- const bottomPanelInset = computeRow(leftCol.rowB.w, rightCol.rowB.w, leftCol.rowB.h, rightCol.rowB.h, availableW / RATIO, bottomInset, gap)
105
+ const leftPanelInset = computeColumn(rawTL, rawBL, availableH / RATIO, main.offsetLeft + left.offsetLeft, gap)
106
+ const rightPanelInset = computeColumn(rawTR, rawBR, availableH / RATIO, left.offsetLeft, gap)
107
+ const topPanelInset = computeRow(rawTL, rawTR, availableW / RATIO, baseTop, gap)
108
+ const bottomPanelInset = computeRow(rawBL, rawBR, availableW / RATIO, bottomInset, gap)
145
109
 
146
110
  const usableW = main.offsetWidth - 2 * gap
147
111
  const usableH = main.offsetHeight - 2 * gap
@@ -25,13 +25,10 @@ const COMBINED_BELOW = 100 // two × 100 + gap = 208 < 236
25
25
 
26
26
  // Width threshold: availableWidth / RATIO = (900-58-58)/2 = 392
27
27
  const ABOVE_W_THRESHOLD = 400
28
- const COMBINED_ABOVE_W = 200 // two × 200 = 400 > 392 → triggers combined
29
- const COMBINED_BELOW_W = 180 // two × 180 = 360 < 392 → does not trigger
30
- const PANEL_H_TALL = 150
31
- const PANEL_H_SHORT = 100
28
+ const COMBINED_BELOW_W = 180
32
29
 
33
- const ABOVE_CAP_TOP = 330 // 60+330+8=398 > CAP_HEIGHT ≈ 389.3 → capped
34
30
  const BOTTOM_INSET = MAIN_HEIGHT - BOTTOM_TOP + GAP // 48: divider-gap above top of bottom container
31
+ const ABOVE_CAP_TOP = 330 // 60+330+8=398 > CAP_HEIGHT ≈ 389.3 → capped
35
32
  const ABOVE_CAP_BOTTOM = 342 // 48+342+8=398 > CAP_HEIGHT → capped
36
33
 
37
34
  const PANEL_W_STANDARD = 200
@@ -42,6 +39,7 @@ const PANEL_W_XLARGE = 600 // 10+600+8=618 > CAP_WIDTH ≈ 589.3 → capped
42
39
  const leftInset = w => LEFT_LEFT + w + GAP
43
40
  const rightInset = w => LEFT_LEFT + w + GAP
44
41
  const topInset = h => BASE_TOP + h + GAP
42
+ const bottomInset = h => BOTTOM_INSET + h + GAP
45
43
 
46
44
  const MAX_RATIO = 3
47
45
  const CAP_WIDTH = (MAIN_WIDTH - 2 * GAP) * (MAX_RATIO - 1) / MAX_RATIO
@@ -192,46 +190,22 @@ describe('getSafeZoneInset — right edge', () => {
192
190
  })
193
191
 
194
192
  // ─── Top edge ────────────────────────────────────────────────────────────────
195
- // Trigger is WIDTH-based. A narrow panel must not add top padding even if tall.
193
+ // Trigger is WIDTH-based, evaluated independently of the column (height-based) trigger.
196
194
 
197
195
  describe('getSafeZoneInset — top edge', () => {
198
196
  it('does not trigger when top panel is narrow, even if tall', () => {
199
- // PANEL_W_STANDARD (200) < width threshold (392)
200
197
  expect(getSafeZoneInset({ ...base(), rightTopRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) }).top).toBe(BASE_TOP)
201
198
  })
202
- it('triggers when a top panel width exceeds threshold', () => {
203
- expect(getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) }).top)
204
- .toBe(topInset(ABOVE_THRESHOLD))
205
- })
206
- it('column-primary wide-and-tall top panel triggers left inset, not top', () => {
207
- // panel(400,330): h/availableH≈0.699 > w/availableW≈0.510 → column-primary
199
+ it('triggers when a top panel is wide enough, capped at (MAX_RATIO-1)/MAX_RATIO of usable height', () => {
208
200
  const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_TOP) })
201
+ expect(result.top).toBe(CAP_HEIGHT)
209
202
  expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
210
- expect(result.top).toBe(BASE_TOP)
211
- })
212
- it('when top panels have mixed primaries, each contributes to its own edge', () => {
213
- // tl(400,100): row-primary → top; tr(400,330): column-primary → right
214
- const result = getSafeZoneInset({
215
- ...base(),
216
- leftTopRef: panel(ABOVE_W_THRESHOLD, COMBINED_BELOW),
217
- rightTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_TOP)
218
- })
219
- expect(result.top).toBe(topInset(COMBINED_BELOW))
220
- expect(result.right).toBe(rightInset(ABOVE_W_THRESHOLD))
221
- })
222
- it('triggers when combined width of two top panels exceeds threshold; uses max height', () => {
223
- // each COMBINED_ABOVE_W (200) < threshold (392), but 200+200=400 > 392
224
- expect(getSafeZoneInset({
225
- ...base(),
226
- leftTopRef: panel(COMBINED_ABOVE_W, PANEL_H_TALL),
227
- rightTopRef: panel(COMBINED_ABOVE_W, PANEL_H_SHORT)
228
- }).top).toBe(topInset(PANEL_H_TALL))
229
203
  })
230
- it('does not trigger when both top panels are below combined width threshold', () => {
204
+ it('does not trigger when combined width of two top panels is below threshold', () => {
231
205
  expect(getSafeZoneInset({
232
206
  ...base(),
233
- leftTopRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD),
234
- rightTopRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD)
207
+ leftTopRef: panel(COMBINED_BELOW_W, BELOW_THRESHOLD),
208
+ rightTopRef: panel(COMBINED_BELOW_W, BELOW_THRESHOLD)
235
209
  }).top).toBe(BASE_TOP)
236
210
  })
237
211
  })
@@ -242,28 +216,21 @@ describe('getSafeZoneInset — bottom edge', () => {
242
216
  it('does not trigger when bottom panel is narrow, even if tall', () => {
243
217
  expect(getSafeZoneInset({ ...base(), rightBottomRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) }).bottom).toBe(BASE_BOTTOM)
244
218
  })
245
- it('triggers when a bottom panel width exceeds threshold', () => {
246
- expect(getSafeZoneInset({ ...base(), leftBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) }).bottom)
247
- .toBe(Math.min(BOTTOM_INSET + ABOVE_THRESHOLD + GAP, CAP_HEIGHT))
248
- })
249
- it('column-primary wide-and-tall bottom panel triggers left inset, not bottom', () => {
250
- // panel(400,342): h/availableH≈0.724 > w/availableW≈0.510 → column-primary
219
+ it('triggers when a bottom panel is wide enough, capped at (MAX_RATIO-1)/MAX_RATIO of usable height', () => {
251
220
  const result = getSafeZoneInset({ ...base(), leftBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_BOTTOM) })
221
+ expect(result.bottom).toBe(CAP_HEIGHT)
252
222
  expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
253
- expect(result.bottom).toBe(BASE_BOTTOM)
254
223
  })
255
- it('triggers when combined width of two bottom panels exceeds threshold; uses max height', () => {
256
- expect(getSafeZoneInset({
257
- ...base(),
258
- leftBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_TALL),
259
- rightBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_SHORT)
260
- }).bottom).toBe(Math.min(BOTTOM_INSET + PANEL_H_TALL + GAP, CAP_HEIGHT))
224
+ it('triggers when a bottom panel is wide enough, using the taller of the two slots', () => {
225
+ const result = getSafeZoneInset({ ...base(), rightBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) })
226
+ expect(result.bottom).toBe(bottomInset(ABOVE_THRESHOLD))
227
+ expect(result.right).toBe(rightInset(ABOVE_W_THRESHOLD))
261
228
  })
262
- it('does not trigger when both bottom panels are below combined width threshold', () => {
229
+ it('does not trigger when combined width of two bottom panels is below threshold', () => {
263
230
  expect(getSafeZoneInset({
264
231
  ...base(),
265
- leftBottomRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD),
266
- rightBottomRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD)
232
+ leftBottomRef: panel(COMBINED_BELOW_W, BELOW_THRESHOLD),
233
+ rightBottomRef: panel(COMBINED_BELOW_W, BELOW_THRESHOLD)
267
234
  }).bottom).toBe(BASE_BOTTOM)
268
235
  })
269
236
  })
@@ -279,30 +246,35 @@ describe('getSafeZoneInset — MAX_RATIO cap', () => {
279
246
  })
280
247
  })
281
248
 
282
- // ─── Corner panel independence ────────────────────────────────────────────────
249
+ // ─── Row/column independence ───────────────────────────────────────────────────
250
+ // A panel that is both tall enough and wide enough triggers BOTH its column (left/
251
+ // right) and row (top/bottom) insets at once — not an exclusive either/or pick.
252
+ // MAX_RATIO (tested above) is what keeps this from consuming the whole viewport.
283
253
 
284
- describe('getSafeZoneInset — corner panel independence', () => {
285
- it('narrow-but-tall corner panel triggers side inset only (not top)', () => {
286
- // PANEL_W_STANDARD (200): tall enough for right, too narrow for top (< 392)
254
+ describe('getSafeZoneInset — row/column independence', () => {
255
+ it('narrow-but-tall panel triggers only its column inset, not the row', () => {
287
256
  const result = getSafeZoneInset({ ...base(), rightTopRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) })
288
257
  expect(result.right).toBe(rightInset(PANEL_W_STANDARD))
289
258
  expect(result.top).toBe(BASE_TOP)
290
259
  })
291
- it('wide-and-tall corner panel triggers only its primary (row) edge inset', () => {
292
- // panel(400, 240): w/availableW≈0.510, h/availableH≈0.508 → row-primary → top only
293
- const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) })
260
+ it('wide-but-short panel triggers only its row inset, not the column', () => {
261
+ const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, BELOW_THRESHOLD) })
262
+ expect(result.top).toBe(topInset(BELOW_THRESHOLD))
294
263
  expect(result.left).toBe(BASE_LEFT)
264
+ })
265
+ it('wide-and-tall panel triggers both its column and row insets at once', () => {
266
+ const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) })
267
+ expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
295
268
  expect(result.top).toBe(topInset(ABOVE_THRESHOLD))
296
269
  })
297
- it('two wide panels in the same column collectively trigger left but not top or bottom', () => {
298
- // Each h=COMBINED_ABOVE (120) < hThreshold individually, combined 248 > 236
299
- // Each w=ABOVE_W_THRESHOLD (400) > wThreshold → left column triggers, excluding from top/bottom
270
+ it('two narrow panels in the same column collectively trigger left, independent of row', () => {
271
+ // Each h=COMBINED_ABOVE (120) < hThreshold individually, combined 248 > 236 → left triggers
300
272
  const result = getSafeZoneInset({
301
273
  ...base(),
302
- leftTopRef: panel(ABOVE_W_THRESHOLD, COMBINED_ABOVE),
303
- leftBottomRef: panel(ABOVE_W_THRESHOLD, COMBINED_ABOVE)
274
+ leftTopRef: panel(PANEL_W_STANDARD, COMBINED_ABOVE),
275
+ leftBottomRef: panel(PANEL_W_STANDARD, COMBINED_ABOVE)
304
276
  })
305
- expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
277
+ expect(result.left).toBe(leftInset(PANEL_W_STANDARD))
306
278
  expect(result.top).toBe(BASE_TOP)
307
279
  expect(result.bottom).toBe(BASE_BOTTOM)
308
280
  })