diff --git a/Source/Common/DatePickerInputImplementation.tsx b/Source/Common/DatePickerInputImplementation.tsx index 9e7225e9..dfb9d251 100644 --- a/Source/Common/DatePickerInputImplementation.tsx +++ b/Source/Common/DatePickerInputImplementation.tsx @@ -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, @@ -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'; @@ -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' diff --git a/Source/Common/TooltipImplementation.tsx b/Source/Common/TooltipImplementation.tsx index 7a2e36c8..2ed3cb84 100644 --- a/Source/Common/TooltipImplementation.tsx +++ b/Source/Common/TooltipImplementation.tsx @@ -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 { @@ -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, { @@ -52,6 +59,7 @@ export const TooltipImplementation = ({ placement={position} offset={8} className='cratis-tooltip-popup' + style={{ zIndex: resolvedZIndex }} data-cratis-part='popup' data-open > diff --git a/Source/Dialogs/DialogImplementation.tsx b/Source/Dialogs/DialogImplementation.tsx index fd2ebfa7..98be4e39 100644 --- a/Source/Dialogs/DialogImplementation.tsx +++ b/Source/Dialogs/DialogImplementation.tsx @@ -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) => values.filter(Boolean).join(' '); @@ -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(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 @@ -218,6 +240,7 @@ export const DialogImplementation = ({ }; const dialogDocument = ( + <>
+ ); const dialogStyle = { width, ...pt?.root?.style, ...style }; @@ -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' @@ -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' diff --git a/Source/Dialogs/for_Dialog/when_opened_from_another_dialog.tsx b/Source/Dialogs/for_Dialog/when_opened_from_another_dialog.tsx new file mode 100644 index 00000000..dba77958 --- /dev/null +++ b/Source/Dialogs/for_Dialog/when_opened_from_another_dialog.tsx @@ -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); + }); +}); diff --git a/Source/Dropdown/DropdownImplementation.tsx b/Source/Dropdown/DropdownImplementation.tsx index 54f630eb..5d9a84bb 100644 --- a/Source/Dropdown/DropdownImplementation.tsx +++ b/Source/Dropdown/DropdownImplementation.tsx @@ -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, @@ -132,6 +134,11 @@ export const DropdownImplementation = ({ }: DropdownProps) => { 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 = @@ -310,7 +317,7 @@ export const DropdownImplementation = ({ panelClassName, )} style={{ - zIndex: 'var(--cratis-z-index-overlay)', + zIndex: resolvedPopoverZIndex, ...pt?.popover?.style, }} data-cratis-part='popover' @@ -531,7 +538,7 @@ export const DropdownImplementation = ({ panelClassName, )} style={{ - zIndex: 'var(--cratis-z-index-overlay)', + zIndex: resolvedPopoverZIndex, ...pt?.popover?.style, }} data-cratis-part='popover' @@ -678,7 +685,7 @@ export const DropdownImplementation = ({ panelClassName, )} style={{ - zIndex: 'var(--cratis-z-index-overlay)', + zIndex: resolvedPopoverZIndex, ...pt?.popover?.style, }} data-cratis-part='popover' 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 new file mode 100644 index 00000000..5c04c708 --- /dev/null +++ b/Source/Dropdown/for_Dropdown/when_opened_inside_a_dialog_opened_from_another_dialog.ts @@ -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); + }); +}); diff --git a/Source/renderer/DialogStackContext.ts b/Source/renderer/DialogStackContext.ts new file mode 100644 index 00000000..fa779636 --- /dev/null +++ b/Source/renderer/DialogStackContext.ts @@ -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(null); + +/** Reads the nearest ancestor dialog's resolved z-index. `null` when not nested in a dialog. */ +export const useNearestDialogZIndex = (): number | null => useContext(DialogStackContext); diff --git a/Source/renderer/dialogStack.ts b/Source/renderer/dialogStack.ts new file mode 100644 index 00000000..78beaa16 --- /dev/null +++ b/Source/renderer/dialogStack.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Assigns each currently open {@link DialogImplementation} a strictly increasing tier so that + * multiple simultaneously open dialogs - opened from anywhere in the tree, not necessarily nested + * as React children of one another - stack in the order they opened instead of colliding on one + * static z-index. Mirrors the incrementing registry PrimeReact's own ZIndexUtils used to provide, + * which the removed `useOverlayZIndex` hook depended on before Components 4 moved Dialog off + * PrimeReact and onto React Aria. + */ +let nextDialogTier = 0; +const openDialogTiers = new Set(); + +/** Reserves the next dialog tier. Pair with {@link closeDialogTier} when the dialog closes. */ +export const openDialogTier = (): number => { + const tier = nextDialogTier; + nextDialogTier += 1; + openDialogTiers.add(tier); + return tier; +}; + +/** Releases a tier reserved by {@link openDialogTier}. Resets the counter once nothing is open. */ +export const closeDialogTier = (tier: number): void => { + openDialogTiers.delete(tier); + if (openDialogTiers.size === 0) nextDialogTier = 0; +}; + +/** The z-index a dialog with no open ancestor uses today - unchanged from the static token. */ +export const DIALOG_BASE_ZINDEX = 1100; + +/** + * Gap between two dialog tiers. Large enough that a second-tier dialog, and everything it hosts + * (its own overlay/filter/tooltip offsets below), stays above every static token band a first-tier + * dialog's own popovers use (dialog 1100 .. tooltip 1300 today). + */ +export const DIALOG_TIER_STEP = 1000; + +/** Offset above its owning dialog a dropdown/date-picker popover uses - mirrors the static overlay token gap. */ +export const OVERLAY_OFFSET = 100; + +/** Offset above its owning dialog a column filter menu uses - mirrors the static filter token gap. */ +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;