From 997f07ec9d7a3453befc60bd9715d613e25264e5 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 7 Sep 2026 21:13:14 +0200 Subject: [PATCH 1/2] Keep dialog tiers relative to the dialog z-index token Assigning a dialog a stacking tier resolved its z-index to a bare number built on the hardcoded 1100 default, which discarded any override of --cratis-z-index-dialog the application had set. The token was only ever consulted as the pre-tier fallback, so the moment the tier landed the override was gone. That is backwards for a token whose whole purpose is to let an application place dialogs in its own stacking order. Cratis Studio raises it to 10100 so dialogs clear the 9999 full-screen overlay its settings pages live in; with the tier applied, every one of those dialogs dropped back to 1100 and rendered behind the overlay that opened it - in the DOM, focusable, and completely invisible. Resolve a tier to a CSS expression relative to the token instead, and compose popover offsets onto that expression rather than adding to a number. The first tier is the token itself, so a lone dialog stacks exactly where it did before tiering existed. The specs that measure stacking order parsed those numbers straight out of the DOM, so they now share a resolveZIndex helper that substitutes the custom property and folds the calc() arithmetic jsdom does not evaluate itself. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D6XsJXFcUfr9FZnf3EuyCp --- .../Common/DatePickerInputImplementation.tsx | 4 +-- Source/Common/TooltipImplementation.tsx | 4 +-- Source/Dialogs/DialogImplementation.tsx | 8 ++--- .../when_opened_from_another_dialog.tsx | 9 ++++-- Source/Dropdown/DropdownImplementation.tsx | 4 +-- .../when_opened_inside_a_dialog.ts | 13 ++------- ...ide_a_dialog_opened_from_another_dialog.ts | 22 +++++++------- Source/renderer/DialogStackContext.ts | 12 +++++--- Source/renderer/dialogStack.ts | 29 ++++++++++++++++--- .../for_dialog_stack/resolveZIndex.ts | 24 +++++++++++++++ 10 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 Source/renderer/for_dialog_stack/resolveZIndex.ts diff --git a/Source/Common/DatePickerInputImplementation.tsx b/Source/Common/DatePickerInputImplementation.tsx index dfb9d251..8b5f1f59 100644 --- a/Source/Common/DatePickerInputImplementation.tsx +++ b/Source/Common/DatePickerInputImplementation.tsx @@ -22,7 +22,7 @@ import { Heading } from 'react-aria-components/Heading'; import { UNSAFE_PortalProvider } from 'react-aria'; import { unstable_useOverlayEnvironment } from '../renderer/RendererContext'; import { useCratisComponentsConfig } from './CratisComponentsProvider'; -import { OVERLAY_OFFSET } from '../renderer/dialogStack'; +import { OVERLAY_OFFSET, zIndexAboveDialog } from '../renderer/dialogStack'; import { useNearestDialogZIndex } from '../renderer/DialogStackContext'; import { asReactAriaButtonProps } from './reactAriaProps'; import { @@ -82,7 +82,7 @@ export const DatePickerInputImplementation = ({ const resolvedPopoverZIndex = nearestDialogZIndex === null ? 'var(--cratis-z-index-overlay)' - : nearestDialogZIndex + OVERLAY_OFFSET; + : zIndexAboveDialog(nearestDialogZIndex, OVERLAY_OFFSET); const { messages } = useCratisComponentsConfig(); const datePickerMessages = messages?.datePicker; const resolvedTodayLabel = todayLabel ?? datePickerMessages?.today ?? 'Today'; diff --git a/Source/Common/TooltipImplementation.tsx b/Source/Common/TooltipImplementation.tsx index 2ed3cb84..217ea58b 100644 --- a/Source/Common/TooltipImplementation.tsx +++ b/Source/Common/TooltipImplementation.tsx @@ -9,7 +9,7 @@ import { } from 'react-aria-components/Tooltip'; import { UNSAFE_PortalProvider } from 'react-aria'; import { unstable_useOverlayEnvironment } from '../renderer/RendererContext'; -import { TOOLTIP_OFFSET } from '../renderer/dialogStack'; +import { TOOLTIP_OFFSET, zIndexAboveDialog } from '../renderer/dialogStack'; import { useNearestDialogZIndex } from '../renderer/DialogStackContext'; import type { TooltipProps } from './Tooltip'; @@ -33,7 +33,7 @@ export const TooltipImplementation = ({ const resolvedZIndex = nearestDialogZIndex === null ? 'var(--cratis-z-index-tooltip)' - : nearestDialogZIndex + TOOLTIP_OFFSET; + : zIndexAboveDialog(nearestDialogZIndex, TOOLTIP_OFFSET); if (!content || disabled) return children; const trigger = cloneElement(children, { diff --git a/Source/Dialogs/DialogImplementation.tsx b/Source/Dialogs/DialogImplementation.tsx index 98be4e39..99b72f1c 100644 --- a/Source/Dialogs/DialogImplementation.tsx +++ b/Source/Dialogs/DialogImplementation.tsx @@ -11,7 +11,7 @@ import { DialogInitialFocus } from './DialogInitialFocus'; import type { DialogProps } from './Dialog'; import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider'; import { - DIALOG_BASE_ZINDEX, + DIALOG_ZINDEX_TOKEN, closeDialogTier, dialogZIndexForTier, openDialogTier, @@ -240,7 +240,7 @@ export const DialogImplementation = ({ }; const dialogDocument = ( - + <>
let secondZIndex: number; beforeEach(async () => { + // Tiers resolve relative to this token rather than to a hardcoded number, so it has to be + // defined for the stacking order to be measurable at all. + document.documentElement.style.setProperty('--cratis-z-index-dialog', '1100'); first = await render( React.createElement( React.Fragment, @@ -41,11 +45,12 @@ describe('when a dialog is opened from another dialog that is still open', () => '.cratis-dialog__backdrop[data-cratis-part="backdrop"]', ); const [firstBackdrop, secondBackdrop] = Array.from(backdrops) as HTMLElement[]; - firstZIndex = Number.parseInt(firstBackdrop.style.zIndex, 10); - secondZIndex = Number.parseInt(secondBackdrop.style.zIndex, 10); + firstZIndex = resolveZIndex(firstBackdrop); + secondZIndex = resolveZIndex(secondBackdrop); }); afterEach(async () => { + document.documentElement.style.removeProperty('--cratis-z-index-dialog'); await unmount(first); }); diff --git a/Source/Dropdown/DropdownImplementation.tsx b/Source/Dropdown/DropdownImplementation.tsx index 5d9a84bb..97606f65 100644 --- a/Source/Dropdown/DropdownImplementation.tsx +++ b/Source/Dropdown/DropdownImplementation.tsx @@ -23,7 +23,7 @@ import { import { UNSAFE_PortalProvider } from 'react-aria'; import { unstable_useOverlayEnvironment } from '../renderer/RendererContext'; import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider'; -import { OVERLAY_OFFSET } from '../renderer/dialogStack'; +import { OVERLAY_OFFSET, zIndexAboveDialog } from '../renderer/dialogStack'; import { useNearestDialogZIndex } from '../renderer/DialogStackContext'; import type { DropdownProps } from './Dropdown'; import { @@ -138,7 +138,7 @@ export const DropdownImplementation = ({ const resolvedPopoverZIndex = nearestDialogZIndex === null ? 'var(--cratis-z-index-overlay)' - : nearestDialogZIndex + OVERLAY_OFFSET; + : zIndexAboveDialog(nearestDialogZIndex, OVERLAY_OFFSET); const { messages } = useCratisComponentsConfig(); const dropdownMessages = messages?.dropdown; const showOptionsLabel = diff --git a/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog.ts b/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog.ts index 5bc9ed64..c33d84fe 100644 --- a/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog.ts +++ b/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, it } from 'vitest'; import { Dialog } from '../../Dialogs/Dialog'; import { Dropdown } from '../Dropdown'; import { CratisComponentsProvider } from '../../Common/CratisComponentsProvider'; +import { resolveZIndex } from '../../renderer/for_dialog_stack/resolveZIndex'; /** * The dropdown popup must leave the dialog's clipping and stacking context. This is a @@ -92,16 +93,8 @@ describe('when a dropdown is opened inside a dialog', () => { '[data-cratis-part="popover"]', ) as HTMLElement; - const resolvedZIndex = (element: HTMLElement) => { - const declaration = element.style.zIndex || getComputedStyle(element).zIndex; - const variable = declaration.match(/var\((--[^)]+)\)/u)?.[1]; - const value = variable - ? getComputedStyle(document.documentElement).getPropertyValue(variable) - : declaration; - return Number.parseInt(value, 10); - }; - dialogPositionerZIndex = resolvedZIndex(dialogPositioner); - panelZIndex = resolvedZIndex(panel); + dialogPositionerZIndex = resolveZIndex(dialogPositioner); + panelZIndex = resolveZIndex(panel); panelIsInsideTheDialog = dialogPopup.contains(panel); panelIsPortaledToTheBody = document.body.contains(panel) && !container.contains(panel); diff --git a/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog_opened_from_another_dialog.ts b/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog_opened_from_another_dialog.ts index 5c04c708..48bf3a64 100644 --- a/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog_opened_from_another_dialog.ts +++ b/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog_opened_from_another_dialog.ts @@ -10,15 +10,7 @@ import { afterEach, beforeEach, describe, it } from 'vitest'; import { Dialog } from '../../Dialogs/Dialog'; import { Dropdown } from '../Dropdown'; import { CratisComponentsProvider } from '../../Common/CratisComponentsProvider'; - -const resolvedZIndex = (element: HTMLElement) => { - const declaration = element.style.zIndex || getComputedStyle(element).zIndex; - const variable = declaration.match(/var\((--[^)]+)\)/u)?.[1]; - const value = variable - ? getComputedStyle(document.documentElement).getPropertyValue(variable) - : declaration; - return Number.parseInt(value, 10); -}; +import { resolveZIndex } from '../../renderer/for_dialog_stack/resolveZIndex'; /** * A compound case beyond a dropdown in a single dialog: a dropdown opened inside a dialog that @@ -45,6 +37,10 @@ describe('when a dropdown is opened inside a dialog opened from another dialog', disconnect() {} }; + // Dialog tiers resolve relative to these tokens rather than to hardcoded numbers, so they + // have to be defined for the stacking order to be measurable at all. + document.documentElement.style.setProperty('--cratis-z-index-dialog', '1100'); + document.documentElement.style.setProperty('--cratis-z-index-overlay', '1200'); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -94,12 +90,12 @@ describe('when a dropdown is opened inside a dialog opened from another dialog', '.cratis-dialog__backdrop[data-cratis-part="backdrop"]', ); const [firstBackdrop, secondBackdrop] = Array.from(backdrops) as HTMLElement[]; - firstDialogZIndex = resolvedZIndex(firstBackdrop); - secondDialogZIndex = resolvedZIndex(secondBackdrop); + firstDialogZIndex = resolveZIndex(firstBackdrop); + secondDialogZIndex = resolveZIndex(secondBackdrop); const panel = document.querySelector( '[data-cratis-part="popover"]', ) as HTMLElement; - panelZIndex = resolvedZIndex(panel); + panelZIndex = resolveZIndex(panel); }); afterEach(async () => { @@ -107,6 +103,8 @@ describe('when a dropdown is opened inside a dialog opened from another dialog', root.unmount(); }); container.remove(); + document.documentElement.style.removeProperty('--cratis-z-index-dialog'); + document.documentElement.style.removeProperty('--cratis-z-index-overlay'); }); it('should stack the second dialog above the first', () => { diff --git a/Source/renderer/DialogStackContext.ts b/Source/renderer/DialogStackContext.ts index fa779636..4e523a48 100644 --- a/Source/renderer/DialogStackContext.ts +++ b/Source/renderer/DialogStackContext.ts @@ -4,12 +4,16 @@ import { createContext, useContext } from 'react'; /** - * The resolved numeric z-index of the nearest ancestor {@link DialogImplementation}, or `null` - * when rendered outside any dialog. Overlay-producing descendants (dropdown popovers, date-picker + * The resolved z-index of the nearest ancestor {@link DialogImplementation}, or `null` when + * rendered outside any dialog. Overlay-producing descendants (dropdown popovers, date-picker * popovers, tooltips, filter menus) read this to stack themselves above their owning dialog instead * of relying on the static overlay token, which does not account for dialog nesting depth. + * + * This is a CSS expression rather than a number - it stays relative to the `--cratis-z-index-dialog` + * token so an application that retunes that token keeps its dialogs, and everything they host, in + * its own stacking order. Compose onto it with `zIndexAboveDialog` rather than doing arithmetic. */ -export const DialogStackContext = createContext(null); +export const DialogStackContext = createContext(null); /** Reads the nearest ancestor dialog's resolved z-index. `null` when not nested in a dialog. */ -export const useNearestDialogZIndex = (): number | null => useContext(DialogStackContext); +export const useNearestDialogZIndex = (): string | null => useContext(DialogStackContext); diff --git a/Source/renderer/dialogStack.ts b/Source/renderer/dialogStack.ts index 78beaa16..6c8fea1e 100644 --- a/Source/renderer/dialogStack.ts +++ b/Source/renderer/dialogStack.ts @@ -26,7 +26,16 @@ export const closeDialogTier = (tier: number): void => { if (openDialogTiers.size === 0) nextDialogTier = 0; }; -/** The z-index a dialog with no open ancestor uses today - unchanged from the static token. */ +/** + * The custom property every tier is measured from. Tiers resolve to a CSS expression built on this + * token rather than to a plain number, because an application is expected to retune the token to fit + * its own stacking order - Cratis Studio, for example, raises it to 10100 so dialogs clear the + * full-screen overlay its settings pages live in. Resolving a tier to a bare number would discard + * that override and pin every dialog back to the default band, behind whatever opened it. + */ +export const DIALOG_ZINDEX_TOKEN = 'var(--cratis-z-index-dialog)'; + +/** The default value of {@link DIALOG_ZINDEX_TOKEN}, for applications that do not override it. */ export const DIALOG_BASE_ZINDEX = 1100; /** @@ -45,6 +54,18 @@ export const FILTER_OFFSET = 150; /** Offset above its owning dialog a tooltip uses - mirrors the static tooltip token gap. */ export const TOOLTIP_OFFSET = 200; -/** Resolves the z-index a dialog at the given tier should use. */ -export const dialogZIndexForTier = (tier: number): number => - DIALOG_BASE_ZINDEX + tier * DIALOG_TIER_STEP; +/** + * Resolves the z-index a dialog at the given tier should use, as a CSS expression relative to + * {@link DIALOG_ZINDEX_TOKEN} so an application's override of that token is preserved. The first + * tier is the token itself, so a lone dialog stacks exactly where it did before tiering existed. + */ +export const dialogZIndexForTier = (tier: number): string => + tier === 0 ? DIALOG_ZINDEX_TOKEN : `calc(${DIALOG_ZINDEX_TOKEN} + ${tier * DIALOG_TIER_STEP})`; + +/** + * Stacks an overlay-producing descendant - a dropdown or date-picker popover, a filter menu, a + * tooltip - the given offset above its owning dialog, keeping it relative to whatever expression + * that dialog resolved to. + */ +export const zIndexAboveDialog = (dialogZIndex: string, offset: number): string => + `calc(${dialogZIndex} + ${offset})`; diff --git a/Source/renderer/for_dialog_stack/resolveZIndex.ts b/Source/renderer/for_dialog_stack/resolveZIndex.ts new file mode 100644 index 00000000..4e9a8f72 --- /dev/null +++ b/Source/renderer/for_dialog_stack/resolveZIndex.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Resolves an element's effective z-index in jsdom, which neither substitutes custom properties nor + * evaluates `calc()` for us. + * + * Dialog tiers resolve to expressions relative to `--cratis-z-index-dialog` rather than to plain + * numbers, so that an application overriding that token keeps its dialogs - and every popover they + * host - in its own stacking order. A spec comparing two tiers therefore has to substitute the token + * and fold the arithmetic itself. + * + * The generated expressions only ever add, so summing the integer terms is a faithful evaluation of + * them; this is deliberately not a general `calc()` implementation. + */ +export const resolveZIndex = (element: HTMLElement): number => { + const declaration = element.style.zIndex || getComputedStyle(element).zIndex; + const substituted = declaration.replace(/var\((--[^)]+)\)/gu, (_, name: string) => + getComputedStyle(document.documentElement).getPropertyValue(name).trim(), + ); + const terms = substituted.match(/-?\d+/gu); + if (terms === null) return Number.NaN; + return terms.reduce((total, term) => total + Number.parseInt(term, 10), 0); +}; From 2b3aa678ce58bb1d9abfc5f5457bf12ba8b034be Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 7 Sep 2026 21:13:20 +0200 Subject: [PATCH 2/2] Add specs covering dialog tier z-index resolution Nothing exercised the dialog stack registry directly, which is how a tier resolving to a bare number shipped: every existing spec set the z-index token to its own default, so a hardcoded 1100 and a resolved token were indistinguishable. Cover the registry itself, and add a dialog-level spec that raises the token the way an application does and asserts the rendered dialog still clears the chrome it was opened from. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D6XsJXFcUfr9FZnf3EuyCp --- ...he_application_raises_the_dialog_token.tsx | 75 ++++++++++++++++ .../when_resolving_dialog_z_indexes.ts | 88 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 Source/Dialogs/for_Dialog/when_the_application_raises_the_dialog_token.tsx create mode 100644 Source/renderer/for_dialog_stack/when_resolving_dialog_z_indexes.ts diff --git a/Source/Dialogs/for_Dialog/when_the_application_raises_the_dialog_token.tsx b/Source/Dialogs/for_Dialog/when_the_application_raises_the_dialog_token.tsx new file mode 100644 index 00000000..3d3c5a88 --- /dev/null +++ b/Source/Dialogs/for_Dialog/when_the_application_raises_the_dialog_token.tsx @@ -0,0 +1,75 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// @vitest-environment jsdom + +import { expect } from 'chai'; +import React from 'react'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { Dialog } from '../Dialog'; +import { render, unmount, type DialogInTheDom } from './given/a_dialog_in_the_dom'; +import { resolveZIndex } from '../../renderer/for_dialog_stack/resolveZIndex'; + +/** + * An application is expected to retune `--cratis-z-index-dialog` to fit its own stacking order. + * Cratis Studio raises it to 10100 so that dialogs opened from inside a full-screen overlay - its + * organization settings pages, among others - clear that overlay's own 9999 layer. + * + * Assigning a dialog a tier must not cost it that override. A tier resolved to a bare number put + * every dialog back in the default 1100 band no matter what the application had asked for, which + * left dialogs opened from those settings pages rendering behind the overlay that opened them - + * present in the DOM, correct in every other respect, and completely invisible. + */ +describe('when the application raises the dialog z-index token', () => { + const raisedToken = 10100; + const appChromeZIndex = 9999; + let dialog: DialogInTheDom; + let backdropZIndex: number; + let secondBackdropZIndex: number; + + beforeEach(async () => { + document.documentElement.style.setProperty('--cratis-z-index-dialog', `${raisedToken}`); + dialog = await render( + React.createElement( + React.Fragment, + null, + React.createElement(Dialog, { + title: 'Opened from the overlay', + visible: true, + buttons: null, + }), + React.createElement(Dialog, { + title: 'Opened from the first dialog', + visible: true, + buttons: null, + }), + ), + ); + + const backdrops = Array.from( + document.querySelectorAll('.cratis-dialog__backdrop[data-cratis-part="backdrop"]'), + ) as HTMLElement[]; + backdropZIndex = resolveZIndex(backdrops[0]); + secondBackdropZIndex = resolveZIndex(backdrops[1]); + }); + + afterEach(async () => { + document.documentElement.style.removeProperty('--cratis-z-index-dialog'); + await unmount(dialog); + }); + + it('should place the first dialog at the raised token rather than the default band', () => { + expect(backdropZIndex).to.equal(raisedToken); + }); + + it('should keep the first dialog above the application chrome it was opened from', () => { + expect(backdropZIndex).to.be.greaterThan(appChromeZIndex); + }); + + it('should keep a dialog opened from that dialog above the chrome as well', () => { + expect(secondBackdropZIndex).to.be.greaterThan(appChromeZIndex); + }); + + it('should still stack the later dialog above the earlier one', () => { + expect(secondBackdropZIndex).to.be.greaterThan(backdropZIndex); + }); +}); diff --git a/Source/renderer/for_dialog_stack/when_resolving_dialog_z_indexes.ts b/Source/renderer/for_dialog_stack/when_resolving_dialog_z_indexes.ts new file mode 100644 index 00000000..2a08bbc4 --- /dev/null +++ b/Source/renderer/for_dialog_stack/when_resolving_dialog_z_indexes.ts @@ -0,0 +1,88 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { expect } from 'chai'; +import { afterEach, describe, it } from 'vitest'; +import { + closeDialogTier, + DIALOG_TIER_STEP, + DIALOG_ZINDEX_TOKEN, + dialogZIndexForTier, + OVERLAY_OFFSET, + openDialogTier, + TOOLTIP_OFFSET, + zIndexAboveDialog, +} from '../dialogStack'; + +// The registry is module-level state shared by every dialog, so a spec that reserves a tier has to +// give it back - otherwise the next spec starts from a non-zero tier and asserts against the wrong +// expression. +const reservedTiers: number[] = []; +const reserveTier = () => { + const tier = openDialogTier(); + reservedTiers.push(tier); + return tier; +}; + +afterEach(() => { + while (reservedTiers.length > 0) closeDialogTier(reservedTiers.pop()!); +}); + +describe('when resolving dialog z indexes', () => { + it('should resolve the first tier to the dialog token itself', () => { + expect(dialogZIndexForTier(0)).to.equal(DIALOG_ZINDEX_TOKEN); + }); + + it('should keep every tier relative to the dialog token', () => { + // The regression this guards: resolving a tier to a bare number discards an application's + // override of --cratis-z-index-dialog, dropping the dialog back into the default band and + // behind any app chrome the token was raised above. + for (const tier of [1, 2, 5]) { + expect(dialogZIndexForTier(tier)).to.contain(DIALOG_ZINDEX_TOKEN); + } + }); + + it('should offset each tier by a whole number of tier steps', () => { + expect(dialogZIndexForTier(1)).to.equal(`calc(${DIALOG_ZINDEX_TOKEN} + ${DIALOG_TIER_STEP})`); + expect(dialogZIndexForTier(3)).to.equal(`calc(${DIALOG_ZINDEX_TOKEN} + ${3 * DIALOG_TIER_STEP})`); + }); + + it('should never resolve a tier to a bare number', () => { + for (const tier of [0, 1, 4]) { + expect(Number.isNaN(Number(dialogZIndexForTier(tier)))).to.be.true; + } + }); + + it('should stack a popover above its owning dialog without losing the token', () => { + const dialogZIndex = dialogZIndexForTier(0); + const popover = zIndexAboveDialog(dialogZIndex, OVERLAY_OFFSET); + expect(popover).to.equal(`calc(${DIALOG_ZINDEX_TOKEN} + ${OVERLAY_OFFSET})`); + expect(popover).to.contain(DIALOG_ZINDEX_TOKEN); + }); + + it('should stack a popover above a nested dialog relative to that dialog', () => { + const nested = dialogZIndexForTier(2); + expect(zIndexAboveDialog(nested, TOOLTIP_OFFSET)).to.equal(`calc(${nested} + ${TOOLTIP_OFFSET})`); + }); + + it('should hand out strictly increasing tiers while dialogs stay open', () => { + expect(reserveTier()).to.equal(0); + expect(reserveTier()).to.equal(1); + expect(reserveTier()).to.equal(2); + }); + + it('should restart tiers once every dialog has closed', () => { + const first = openDialogTier(); + const second = openDialogTier(); + closeDialogTier(second); + closeDialogTier(first); + expect(reserveTier()).to.equal(0); + }); + + it('should keep counting up while any dialog is still open', () => { + const outer = reserveTier(); + const inner = openDialogTier(); + closeDialogTier(inner); + expect(reserveTier()).to.be.greaterThan(outer); + }); +});