Skip to content

Fix/map controls above overlapping tiles - #173

Merged
ckrew merged 3 commits into
mainfrom
fix/map-controls-above-overlapping-tiles
Aug 18, 2026
Merged

Fix/map controls above overlapping tiles#173
ckrew merged 3 commits into
mainfrom
fix/map-controls-above-overlapping-tiles

Conversation

@ckrew

@ckrew ckrew commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Raise fill-viewport map controls above overlapping tiles; stop rendering empty headings

Three commits, 10 files, +229 / −6. Two unrelated concerns — the branch name only describes the first. They are separate commits and cherry-pick cleanly if you would rather split them.


1. A fill-viewport map's legend and layer control were hidden by overlapping tiles

547dd1bf

Symptom

Opening a map's legend or layer control drew it behind any grid item overlapping the map — but only when the map filled the viewport, and only outside edit mode.

Cause

fillViewportActive makes the item position: fixed, and a fixed element creates a stacking context even at z-index: auto. That seals the item's subtree in. The legend, layer control, error alert and coordinate readout all set z-index: 1000, but inside a stacking context that only orders them against each other — never against another grid item. The tile paints as one unit in gridItems order, so any tile ordered after the map covered the map's own controls.

Edit mode was unaffected because fillViewportActive is gated on !isEditing, leaving the item position: relative and therefore not a stacking context — which is exactly why the controls behaved there. That observation is what identified the bug; an earlier theory about z-index ties with Bootstrap dropdowns was wrong precisely because it would have behaved identically in both modes.

Fix

No descendant z-index can escape a stacking context, so the item itself is raised while a control is open, lifting the whole subtree. Each control flags its own container with data-map-control-open while expanded, and a :has() rule scoped to the fill-viewport branch raises the item to 1029.

Two consequences, both deliberate:

  • A tile ordered after the map is hidden while a control is open, reverting on close. Accepted rather than portalling each control out of the map and positioning it from a measured bounding rect, which trades a CSS concern for a JS-measured one.
  • 1029 clears dropdowns (1000) and sticky (1020) but stays under the fixed header (1030) and all modal chrome (backdrop 1040, modal 1050, popover 1070, tooltip 1080, app alerts 1081), so a modal still covers the map.

LayerControlContainer also gains the aria-label it was missing, matching the legend's — which is how the test reaches it without the direct DOM access testing-library/no-node-access forbids.


2 & 3. Optional headings rendered as empty elements

3133b15d, 0ec875b4

title is optional in the table return shape; title and description are both optional in card. Both renderers ignored that:

// DataTable — subtitle guarded, title not
<h2>{title}</h2>
{subtitle && <h4>{subtitle}</h4>}

// Card — neither guarded, inside a Header that always rendered
<Header>
  <h3>{title}</h3>
  <p>{description}</p>
</Header>

A plugin returning only data therefore got an empty <h2>, or an empty <h3> + empty <p> + Header's own 1.5rem margin, as blank space above its content. Guarded now, the way subtitle already was. The Card wrapper is guarded too — dropping only the fields would leave the margin behind.

Header carries a data-testid so its absence is observable: an empty wrapper renders no text, so no query could otherwise tell it apart from no wrapper at all.


Verification

131 suites, 2503 tests. Lint and Prettier clean.

Each new test was mutation-checked against the committed code — the mutation must fail the suite and the baseline must restore clean:

Mutation Caught
Remove the raise rule
Raise to 1050 (into modal chrome)
Un-scope the raise from the fill-viewport branch
Legend never flags its container
Legend flags while collapsed
Layer control never flags
Unguard DataTable's title
Unguard Card's title
Always render Card's Header wrapper
Unguard Card's description ✗ — see below

Limitations, stated plainly

  • jsdom does not resolve :has() in computed styles, so the raise cannot be read back through getComputedStyle. The tests assert the injected rule instead: that it ships, what it raises to, that it stays below modal chrome, and that the element actually carries the generated class. They cannot prove paint order — this wants a quick look in a browser with a legend open over an overlapping tile before merging.
  • Unguarding Card's description is not caught. It only produces an empty <p> when a title exists without a description, and Testing Library has no query that distinguishes an empty paragraph from no paragraph without direct DOM access. The guard is correct; it is simply untested.
  • Two mutation attempts initially passed and were only caught after strengthening the tests: unguarding Card's title (masked by the outer wrapper guard until a description-without-title case was added) and always rendering the Header (invisible to text queries until the testid was added).

Notes

  • Needs a frontend rebuild (npm run build) to appear in the Django-served app.
  • :has() is baseline-supported (Chrome 105+, Safari 15.4+, Firefox 121+).
  • The heading guards are what let a card-type plugin drop its title cleanly, which is the immediate motivation — but they apply to every table and card plugin.

ckrew and others added 3 commits August 17, 2026 15:29
… tiles

Opening a map's legend or layer control drew it behind any grid item
overlapping the map, but only when the map filled the viewport, and only
outside edit mode.

fillViewportActive makes the item position:fixed, which creates a stacking
context even at z-index:auto. That seals the item's subtree in: the legend,
layer control, error alert and coordinate readout all set z-index:1000, but
inside a stacking context that only orders them against each other, never
against another grid item. The item paints as one unit in gridItems order,
so any tile ordered after the map covered the map's own controls. Edit mode
was unaffected because fillViewportActive is gated on !isEditing, leaving
the item position:relative and so not a stacking context -- which is why
the controls behaved there.

No descendant z-index can escape a stacking context, so the fix raises the
item itself while a control is open, lifting the whole subtree. Each control
flags its own container with data-map-control-open while expanded, and a
:has() rule scoped to the fill-viewport branch raises the item to 1029.

Two consequences worth naming. A tile ordered after the map is hidden while
a control is open, reverting on close -- accepted deliberately, since the
alternative is portalling each control out of the map and positioning it
from a measured bounding rect. And 1029 clears dropdowns (1000) and sticky
(1020) but stays under the fixed header (1030) and all modal chrome, so a
modal still covers the map.

LayerControlContainer also gains the aria-label it was missing, matching
the legend's, which is how the test reaches it without direct DOM access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
title is optional in the `table` return shape, but DataTable rendered it
unguarded while guarding the subtitle immediately below it. A plugin that
returned only `data` therefore got an empty <h2> -- a heading's worth of
blank space above the table -- rather than no heading.

Guarded the same way the subtitle already was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are optional in the `card` return shape, but Card rendered each
unguarded inside a Header that always rendered. A plugin returning only
`data` therefore got an empty <h3>, an empty <p>, and Header's own 1.5rem
margin above the stats.

Guarded each field, and the wrapper too -- dropping only the fields would
still leave the margin behind. Header carries a testid so its absence is
observable; an empty wrapper renders no text, so no query could otherwise
tell it apart from no wrapper at all.

Same fix as the preceding commit for DataTable's title, which had the
narrower version of this: guarded subtitle, unguarded title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ckrew
ckrew merged commit 0848d5d into main Aug 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant