A rung compacts when a claim is released, so an overlay's z can move without it doing anything. Six of seven call sites read z once at open time and kept a number that was later handed to the next overlay: two of them at one level, DOM order deciding which wins and which eats the clicks. Reproduced with two menus and a third opened after closing the first -- both ended up at 501. claimFor(rung, node) binds the node, so there is nothing left to forget, and the raw claim() stays for the one case with no node. The test walks the registry rather than naming overlays, because the bug was one site remembering and six forgetting. Also drops a no-op ternary that picked the modal rung either way.
121 lines
3.7 KiB
JavaScript
121 lines
3.7 KiB
JavaScript
// The only place a z-index is written.
|
|
//
|
|
// v4 ended up with 9000, 9999, 10001, 12000, 99999, 200000 and two cross-file
|
|
// collisions, because every overlay picked its own number in isolation. Here the
|
|
// ladder is ordered once and each rung owns a block of slots, so peers stack
|
|
// within a rung and can never cross into the next one.
|
|
//
|
|
// A rung is an ORDERED LIST and z is derived from position, rather than each
|
|
// claim owning a fixed offset. That is what makes raise() and release() always
|
|
// correct: with fixed offsets, a near-full rung has no free slot above the top,
|
|
// and "move to the front" has nowhere to go.
|
|
|
|
// Order is the contract; the numbers are derived. Autocomplete sits ABOVE modal
|
|
// on purpose: it opens from a field that is often inside one.
|
|
export const LADDER = [
|
|
"local",
|
|
"dropline",
|
|
"sticky",
|
|
"menubar",
|
|
"popover",
|
|
"backdrop",
|
|
"wizard",
|
|
"modal",
|
|
"floatingWindow",
|
|
"toast",
|
|
"autocomplete",
|
|
"tour",
|
|
];
|
|
|
|
export const SLOTS = 100; // peers per rung
|
|
|
|
const stacks = new Map(); // rung -> entries, bottom to top
|
|
|
|
export function baseOf(rung) {
|
|
const i = LADDER.indexOf(rung);
|
|
if (i === -1) {
|
|
throw new RangeError(`Unknown z rung "${rung}". Known: ${LADDER.join(", ")}.`);
|
|
}
|
|
return (i + 1) * SLOTS;
|
|
}
|
|
|
|
/**
|
|
* Take a z-index in `rung`. `onChange(z)` fires whenever this claim's z moves --
|
|
* which happens when a PEER raises or releases, not only when this one does.
|
|
* Without it a window that was above becomes stale in the DOM the moment
|
|
* another is brought to the front.
|
|
*/
|
|
export function claim(rung, onChange) {
|
|
const start = baseOf(rung);
|
|
if (!stacks.has(rung)) stacks.set(rung, []);
|
|
const list = stacks.get(rung);
|
|
|
|
if (list.length >= SLOTS) {
|
|
// Running a rung dry means something opens overlays and never releases
|
|
// them; quietly reusing a slot would hide the leak and stack two things at
|
|
// one z.
|
|
throw new RangeError(`z rung "${rung}" is full (${SLOTS} live claims). Something is not releasing.`);
|
|
}
|
|
|
|
const entry = { onChange };
|
|
list.push(entry);
|
|
let live = true;
|
|
let lastZ = start + list.indexOf(entry);
|
|
|
|
const handle = {
|
|
get z() {
|
|
if (!live) return lastZ;
|
|
lastZ = start + list.indexOf(entry);
|
|
return lastZ;
|
|
},
|
|
get rung() { return rung; },
|
|
get live() { return live; },
|
|
raise() {
|
|
if (!live) return handle;
|
|
const i = list.indexOf(entry);
|
|
if (i === -1 || i === list.length - 1) return handle;
|
|
list.splice(i, 1);
|
|
list.push(entry);
|
|
notify(list, start);
|
|
return handle;
|
|
},
|
|
release() {
|
|
if (!live) return;
|
|
lastZ = handle.z;
|
|
const i = list.indexOf(entry);
|
|
if (i !== -1) list.splice(i, 1);
|
|
live = false;
|
|
notify(list, start);
|
|
},
|
|
};
|
|
return handle;
|
|
}
|
|
|
|
function notify(list, start) {
|
|
list.forEach((entry, i) => { if (entry.onChange) entry.onChange(start + i); });
|
|
}
|
|
|
|
/**
|
|
* Claim a rung AND keep `node`'s z-index in sync with it -- the form every
|
|
* overlay should use.
|
|
*
|
|
* `claim()` alone hands back a number and trusts the caller to notice when it
|
|
* changes, and a caller that forgets gets a z-index that was right at open time
|
|
* and silently wrong afterwards: a peer releasing compacts the rung, and the
|
|
* next overlay opened lands on the number this one is still displaying. Six of
|
|
* the seven call sites forgot. Binding the node here means there is nothing
|
|
* left to forget.
|
|
*/
|
|
export function claimFor(rung, node) {
|
|
const handle = claim(rung, (z) => { node.style.zIndex = String(z); });
|
|
node.style.zIndex = String(handle.z);
|
|
return handle;
|
|
}
|
|
|
|
/** Live claims in a rung, bottom to top. Diagnostics and tests. */
|
|
export const liveCount = (rung) => (stacks.get(rung) || []).length;
|
|
|
|
export function _resetLayers() {
|
|
stacks.clear();
|
|
}
|