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
11 changes: 11 additions & 0 deletions Source/Common/DatePickerInputImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ 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 { useNearestDialogZIndex } from '../renderer/DialogStackContext';
import { asReactAriaButtonProps } from './reactAriaProps';
import {
fromDate,
Expand Down Expand Up @@ -76,6 +78,11 @@ export const DatePickerInputImplementation = ({
}: DatePickerInputProps) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const overlayEnvironment = unstable_useOverlayEnvironment();
const nearestDialogZIndex = useNearestDialogZIndex();
const resolvedPopoverZIndex =
nearestDialogZIndex === null
? 'var(--cratis-z-index-overlay)'
: nearestDialogZIndex + OVERLAY_OFFSET;
const { messages } = useCratisComponentsConfig();
const datePickerMessages = messages?.datePicker;
const resolvedTodayLabel = todayLabel ?? datePickerMessages?.today ?? 'Today';
Expand Down Expand Up @@ -284,6 +291,10 @@ export const DatePickerInputImplementation = ({
'cratis-date-picker__popover',
pt?.popover?.className,
)}
style={{
zIndex: resolvedPopoverZIndex,
...pt?.popover?.style,
}}
data-cratis-part='popover'
data-open
placement='bottom start'
Expand Down
8 changes: 8 additions & 0 deletions Source/Common/TooltipImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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 { useNearestDialogZIndex } from '../renderer/DialogStackContext';
import type { TooltipProps } from './Tooltip';

interface TooltipTriggerElementProps {
Expand All @@ -27,6 +29,11 @@ export const TooltipImplementation = ({
children,
}: TooltipProps) => {
const overlayEnvironment = unstable_useOverlayEnvironment();
const nearestDialogZIndex = useNearestDialogZIndex();
const resolvedZIndex =
nearestDialogZIndex === null
? 'var(--cratis-z-index-tooltip)'
: nearestDialogZIndex + TOOLTIP_OFFSET;
if (!content || disabled) return children;

const trigger = cloneElement(children, {
Expand All @@ -52,6 +59,7 @@ export const TooltipImplementation = ({
placement={position}
offset={8}
className='cratis-tooltip-popup'
style={{ zIndex: resolvedZIndex }}
data-cratis-part='popup'
data-open
>
Expand Down
30 changes: 27 additions & 3 deletions Source/Dialogs/DialogImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import { DialogResult, DialogButtons, useDialogContext } from '@cratis/arc.react
import { Dialog as AriaDialog, Heading } from 'react-aria-components/Dialog';
import { Modal, ModalOverlay } from 'react-aria-components/Modal';
import { UNSAFE_PortalProvider } from 'react-aria';
import { useEffect, useRef, useSyncExternalStore } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { unstable_useOverlayEnvironment } from '../renderer/RendererContext';
import { DialogInitialFocus } from './DialogInitialFocus';
import type { DialogProps } from './Dialog';
import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider';
import {
DIALOG_BASE_ZINDEX,
closeDialogTier,
dialogZIndexForTier,
openDialogTier,
} from '../renderer/dialogStack';
import { DialogStackContext } from '../renderer/DialogStackContext';

const classNames = (...values: Array<string | undefined>) =>
values.filter(Boolean).join(' ');
Expand Down Expand Up @@ -83,6 +90,21 @@ export const DialogImplementation = ({
const allowsDismissal = dismissable ?? typeof buttons === 'number';
const isDismissable = allowsDismissal && !isBusy;

// Assigns this dialog a strictly increasing tier for as long as it is visible, so a second
// dialog opened while this one is still open - from anywhere, not necessarily as a React
// child of this one - stacks above it rather than colliding on the same static z-index.
const [dialogTier, setDialogTier] = useState<number | null>(null);
useLayoutEffect(() => {
if (!visible) {
setDialogTier(null);
return;
}
const tier = openDialogTier();
setDialogTier(tier);
return () => closeDialogTier(tier);
}, [visible]);
const resolvedZIndex = dialogTier === null ? undefined : dialogZIndexForTier(dialogTier);

useEffect(() => {
if (!visible) return;
const target = focusesConfirmButton
Expand Down Expand Up @@ -218,6 +240,7 @@ export const DialogImplementation = ({
};

const dialogDocument = (
<DialogStackContext.Provider value={resolvedZIndex ?? DIALOG_BASE_ZINDEX}>
<AriaDialog className='cratis-dialog__document'>
<>
<header
Expand Down Expand Up @@ -305,6 +328,7 @@ export const DialogImplementation = ({
)}
</>
</AriaDialog>
</DialogStackContext.Provider>
);

const dialogStyle = { width, ...pt?.root?.style, ...style };
Expand All @@ -319,7 +343,7 @@ export const DialogImplementation = ({
{...pt?.backdrop}
className={classNames('cratis-dialog__backdrop', pt?.backdrop?.className)}
style={{
zIndex: 'var(--cratis-z-index-dialog)',
zIndex: resolvedZIndex ?? 'var(--cratis-z-index-dialog)',
...pt?.backdrop?.style,
}}
data-cratis-part='backdrop'
Expand Down Expand Up @@ -368,7 +392,7 @@ export const DialogImplementation = ({
isKeyboardDismissDisabled={!isDismissable}
className={classNames('cratis-dialog__backdrop', pt?.backdrop?.className)}
style={{
zIndex: 'var(--cratis-z-index-dialog)',
zIndex: resolvedZIndex ?? 'var(--cratis-z-index-dialog)',
...pt?.backdrop?.style,
}}
data-cratis-part='backdrop'
Expand Down
59 changes: 59 additions & 0 deletions Source/Dialogs/for_Dialog/when_opened_from_another_dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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';

/**
* A second dialog opened while the first is still visible is not a React child of the first -
* it is typically triggered from a click handler and rendered by whatever ancestor holds both,
* exactly as this spec mounts them: two independent, simultaneously visible dialogs. Nothing
* about their stacking should depend on being nested in the React tree.
*/
describe('when a dialog is opened from another dialog that is still open', () => {
let first: DialogInTheDom;
let firstZIndex: number;
let secondZIndex: number;

beforeEach(async () => {
first = await render(
React.createElement(
React.Fragment,
null,
React.createElement(Dialog, {
title: 'First dialog',
visible: true,
buttons: null,
}),
React.createElement(Dialog, {
title: 'Second dialog',
visible: true,
buttons: null,
}),
),
);

const backdrops = document.querySelectorAll(
'.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);
});

afterEach(async () => {
await unmount(first);
});

it('should give each dialog a distinct z-index', () => {
expect(firstZIndex).to.not.equal(secondZIndex);
});

it('should stack the dialog opened later above the one that was already open', () => {
expect(secondZIndex).to.be.greaterThan(firstZIndex);
});
});
13 changes: 10 additions & 3 deletions Source/Dropdown/DropdownImplementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ 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 { useNearestDialogZIndex } from '../renderer/DialogStackContext';
import type { DropdownProps } from './Dropdown';
import {
asReactAriaButtonProps,
Expand Down Expand Up @@ -132,6 +134,11 @@ export const DropdownImplementation = <T = unknown,>({
}: DropdownProps<T>) => {
const [isOpen, setIsOpen] = useState(false);
const overlayEnvironment = unstable_useOverlayEnvironment();
const nearestDialogZIndex = useNearestDialogZIndex();
const resolvedPopoverZIndex =
nearestDialogZIndex === null
? 'var(--cratis-z-index-overlay)'
: nearestDialogZIndex + OVERLAY_OFFSET;
const { messages } = useCratisComponentsConfig();
const dropdownMessages = messages?.dropdown;
const showOptionsLabel =
Expand Down Expand Up @@ -310,7 +317,7 @@ export const DropdownImplementation = <T = unknown,>({
panelClassName,
)}
style={{
zIndex: 'var(--cratis-z-index-overlay)',
zIndex: resolvedPopoverZIndex,
...pt?.popover?.style,
}}
data-cratis-part='popover'
Expand Down Expand Up @@ -531,7 +538,7 @@ export const DropdownImplementation = <T = unknown,>({
panelClassName,
)}
style={{
zIndex: 'var(--cratis-z-index-overlay)',
zIndex: resolvedPopoverZIndex,
...pt?.popover?.style,
}}
data-cratis-part='popover'
Expand Down Expand Up @@ -678,7 +685,7 @@ export const DropdownImplementation = <T = unknown,>({
panelClassName,
)}
style={{
zIndex: 'var(--cratis-z-index-overlay)',
zIndex: resolvedPopoverZIndex,
...pt?.popover?.style,
}}
data-cratis-part='popover'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
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);
};

/**
* A compound case beyond a dropdown in a single dialog: a dropdown opened inside a dialog that
* was itself opened while another dialog was still open. The dropdown's popover must stack above
* both dialogs, not just the elevated one it is nested in - it cannot fall back to the plain
* overlay token, which sits above only a single, non-elevated dialog tier.
*/
describe('when a dropdown is opened inside a dialog opened from another dialog', () => {
let root: Root;
let container: HTMLDivElement;
let firstDialogZIndex: number;
let secondDialogZIndex: number;
let panelZIndex: number;

beforeEach(async () => {
// SAFETY: React's test-environment flag is an intentionally undocumented global absent from DOM typings.
(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// SAFETY: jsdom omits ResizeObserver; the overlay only calls these observer methods.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
};

container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root.render(
React.createElement(
CratisComponentsProvider,
null,
React.createElement(Dialog, {
title: 'First dialog',
visible: true,
buttons: null,
}),
React.createElement(Dialog, {
title: 'Second dialog',
visible: true,
buttons: null,
children: React.createElement(Dropdown, {
options: [
{ id: '1', name: 'One' },
{ id: '2', name: 'Two' },
],
optionLabel: 'name',
optionValue: 'id',
'aria-label': 'Pick value',
}),
}),
),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});

const trigger = document.querySelector(
'[data-cratis-part="trigger"]',
) as HTMLElement;
await act(async () => {
trigger.click();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});

const backdrops = document.querySelectorAll(
'.cratis-dialog__backdrop[data-cratis-part="backdrop"]',
);
const [firstBackdrop, secondBackdrop] = Array.from(backdrops) as HTMLElement[];
firstDialogZIndex = resolvedZIndex(firstBackdrop);
secondDialogZIndex = resolvedZIndex(secondBackdrop);
const panel = document.querySelector(
'[data-cratis-part="popover"]',
) as HTMLElement;
panelZIndex = resolvedZIndex(panel);
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});

it('should stack the second dialog above the first', () => {
expect(secondDialogZIndex).to.be.greaterThan(firstDialogZIndex);
});

it("should stack the dropdown panel above the dialog it was opened from", () => {
expect(panelZIndex).to.be.greaterThan(secondDialogZIndex);
});

it('should stack the dropdown panel above the first dialog too', () => {
expect(panelZIndex).to.be.greaterThan(firstDialogZIndex);
});
});
15 changes: 15 additions & 0 deletions Source/renderer/DialogStackContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

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
* 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.
*/
export const DialogStackContext = createContext<number | 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);
Loading
Loading