Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Source/Common/DatePickerInputImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions Source/Common/TooltipImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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, {
Expand Down
8 changes: 4 additions & 4 deletions Source/Dialogs/DialogImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,7 +240,7 @@ export const DialogImplementation = ({
};

const dialogDocument = (
<DialogStackContext.Provider value={resolvedZIndex ?? DIALOG_BASE_ZINDEX}>
<DialogStackContext.Provider value={resolvedZIndex ?? DIALOG_ZINDEX_TOKEN}>
<AriaDialog className='cratis-dialog__document'>
<>
<header
Expand Down Expand Up @@ -343,7 +343,7 @@ export const DialogImplementation = ({
{...pt?.backdrop}
className={classNames('cratis-dialog__backdrop', pt?.backdrop?.className)}
style={{
zIndex: resolvedZIndex ?? 'var(--cratis-z-index-dialog)',
zIndex: resolvedZIndex ?? DIALOG_ZINDEX_TOKEN,
...pt?.backdrop?.style,
}}
data-cratis-part='backdrop'
Expand Down Expand Up @@ -392,7 +392,7 @@ export const DialogImplementation = ({
isKeyboardDismissDisabled={!isDismissable}
className={classNames('cratis-dialog__backdrop', pt?.backdrop?.className)}
style={{
zIndex: resolvedZIndex ?? 'var(--cratis-z-index-dialog)',
zIndex: resolvedZIndex ?? DIALOG_ZINDEX_TOKEN,
...pt?.backdrop?.style,
}}
data-cratis-part='backdrop'
Expand Down
9 changes: 7 additions & 2 deletions Source/Dialogs/for_Dialog/when_opened_from_another_dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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';

/**
* A second dialog opened while the first is still visible is not a React child of the first -
Expand All @@ -20,6 +21,9 @@ describe('when a dialog is opened from another dialog that is still open', () =>
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,
Expand All @@ -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);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions Source/Dropdown/DropdownImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -138,7 +138,7 @@ export const DropdownImplementation = <T = unknown,>({
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 =
Expand Down
13 changes: 3 additions & 10 deletions Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -94,19 +90,21 @@ 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 () => {
await act(async () => {
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', () => {
Expand Down
12 changes: 8 additions & 4 deletions Source/renderer/DialogStackContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
export const DialogStackContext = createContext<string | null>(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);
29 changes: 25 additions & 4 deletions Source/renderer/dialogStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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})`;
24 changes: 24 additions & 0 deletions Source/renderer/for_dialog_stack/resolveZIndex.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading
Loading