diff --git a/packages/react/src/components/dialog-alike/F0Dialog/internal/DialogInternal.tsx b/packages/react/src/components/dialog-alike/F0Dialog/internal/DialogInternal.tsx index 718a59c018..896f90b412 100644 --- a/packages/react/src/components/dialog-alike/F0Dialog/internal/DialogInternal.tsx +++ b/packages/react/src/components/dialog-alike/F0Dialog/internal/DialogInternal.tsx @@ -26,6 +26,7 @@ export const DialogInternal: FC = ({ variant = "default", type = "default", container, + fullHeight = false, }) => { const [localIsOpen, setLocalIsOpen] = useState(isOpen) @@ -86,6 +87,7 @@ export const DialogInternal: FC = ({ modal={modal} onOpenChange={setLocalIsOpen} container={container} + fullHeight={fullHeight} > {_memoizedDialogLayout} diff --git a/packages/react/src/components/dialog-alike/common/Content.tsx b/packages/react/src/components/dialog-alike/common/Content.tsx index 6448c1c6a7..80641fd45a 100644 --- a/packages/react/src/components/dialog-alike/common/Content.tsx +++ b/packages/react/src/components/dialog-alike/common/Content.tsx @@ -41,7 +41,7 @@ export const Content = ({ children, disableContentPadding = false, }: ContentProps) => { - const { position } = useF0Dialog() + const { position, fullHeight } = useF0Dialog() const viewportRef = useRef(null) const [isAtTop, setIsAtTop] = useState(true) const [isAtBottom, setIsAtBottom] = useState(true) @@ -70,14 +70,14 @@ export const Content = ({ }, [handleScroll]) return ( -
+
div]:py-4", - position === "fullscreen" && + (position === "fullscreen" || fullHeight) && "h-full [&>div]:h-full [&>div>div]:h-full" )} > diff --git a/packages/react/src/components/dialog-alike/common/DialogWrapperProvider.tsx b/packages/react/src/components/dialog-alike/common/DialogWrapperProvider.tsx index b21c4e0fd5..708b8551fa 100644 --- a/packages/react/src/components/dialog-alike/common/DialogWrapperProvider.tsx +++ b/packages/react/src/components/dialog-alike/common/DialogWrapperProvider.tsx @@ -7,6 +7,7 @@ export type DialogWrapperContextType = { onClose: () => void shownBottomSheet: boolean position: Position + fullHeight: boolean /** * The dialog's content container element. * Use this as the `portalContainer` prop for components like F0Select @@ -23,6 +24,7 @@ export type DialogWrapperProviderProps = { onClose: () => void shownBottomSheet?: boolean position: Position + fullHeight?: boolean children: ReactNode portalContainer: HTMLDivElement | null } @@ -31,6 +33,7 @@ export const DialogWrapperContext = createContext({ open: false, onClose: () => {}, position: "center", + fullHeight: false, shownBottomSheet: false, portalContainer: null, }) @@ -40,6 +43,7 @@ export const DialogWrapperProvider = ({ onClose, shownBottomSheet = false, position, + fullHeight = false, children, portalContainer, }: DialogWrapperProviderProps) => { @@ -49,6 +53,7 @@ export const DialogWrapperProvider = ({ open: isOpen, onClose, position, + fullHeight, shownBottomSheet, portalContainer, }} diff --git a/packages/react/src/components/dialog-alike/common/Wrapper.tsx b/packages/react/src/components/dialog-alike/common/Wrapper.tsx index 670a5d7355..618b2d08cd 100644 --- a/packages/react/src/components/dialog-alike/common/Wrapper.tsx +++ b/packages/react/src/components/dialog-alike/common/Wrapper.tsx @@ -187,6 +187,7 @@ export const DialogWrapper = ({ isOpen={isOpen} onClose={onClose} position={position} + fullHeight={fullHeight} portalContainer={containerElement ?? null} shownBottomSheet > diff --git a/packages/react/src/components/dialog-alike/common/__tests__/Content.test.tsx b/packages/react/src/components/dialog-alike/common/__tests__/Content.test.tsx new file mode 100644 index 0000000000..8902523a62 --- /dev/null +++ b/packages/react/src/components/dialog-alike/common/__tests__/Content.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { zeroRender as render } from "@/testing/test-utils" + +import { DialogWrapperProvider } from "../DialogWrapperProvider" +import { Content } from "../Content" + +const renderContent = ( + children: React.ReactNode, + { fullHeight = false }: { fullHeight?: boolean } = {} +) => + render( + {}} + position="center" + fullHeight={fullHeight} + portalContainer={null} + > + {children} + + ) + +describe("Content", () => { + it("lets its flex-1 wrapper shrink below content size so the inner ScrollArea can scroll instead of clipping", () => { + const { container } = renderContent(
Step content
) + + const wrapper = container.querySelector(".flex-1.flex-col.overflow-hidden") + expect(wrapper).not.toBeNull() + expect(wrapper).toHaveClass("min-h-0") + }) + + it("gives the ScrollArea a real height when the dialog is fullHeight, not just when position is fullscreen", () => { + const { container: withoutFullHeight } = renderContent(
Content
) + const { container: withFullHeight } = renderContent(
Content
, { + fullHeight: true, + }) + + const viewportWrapper = (container: HTMLElement) => + container.querySelector("[data-scroll-container]")?.parentElement + + expect(viewportWrapper(withoutFullHeight)).not.toHaveClass("h-full") + expect(viewportWrapper(withFullHeight)).toHaveClass("h-full") + }) + + it("hides the bottom scroll shadow only once the viewport has actually reached the end of the content", async () => { + const { container } = renderContent( +
Last field
+ ) + + const viewport = container.querySelector( + "[data-scroll-container]" + ) + expect(viewport).not.toBeNull() + + // jsdom never lays out real content, so scrollHeight/clientHeight are + // always 0. Fake them to simulate a step tall enough to overflow, with + // "last-field" past the fold — mirrors the it_management form that + // triggered the original clipping bug. + Object.defineProperty(viewport, "scrollHeight", { value: 1000 }) + Object.defineProperty(viewport, "clientHeight", { value: 300 }) + Object.defineProperty(viewport, "scrollTop", { + value: 0, + writable: true, + }) + fireEvent.scroll(viewport!) + + const bottomShadow = () => container.querySelector(".bottom-0.h-4") + + await waitFor(() => expect(bottomShadow()).not.toBeNull()) + + viewport!.scrollTop = 700 + fireEvent.scroll(viewport!) + + await waitFor(() => expect(bottomShadow()).toBeNull()) + }) +}) diff --git a/packages/react/src/components/dialog-alike/common/__tests__/Wrapper.test.tsx b/packages/react/src/components/dialog-alike/common/__tests__/Wrapper.test.tsx index 4831f62008..51103bc15a 100644 --- a/packages/react/src/components/dialog-alike/common/__tests__/Wrapper.test.tsx +++ b/packages/react/src/components/dialog-alike/common/__tests__/Wrapper.test.tsx @@ -68,6 +68,21 @@ describe("DialogWrapper portal target", () => { } ) + it("applies h-full to DialogContent when fullHeight is set", () => { + render() + + expect(dialogContentSpy).toHaveBeenCalledWith( + expect.objectContaining({ className: expect.stringContaining("h-full") }) + ) + }) + + it("does not apply h-full to DialogContent when fullHeight is unset", () => { + render() + + const { className } = dialogContentSpy.mock.calls[0][0] + expect(className).not.toMatch(/(^|\s)h-full(\s|$)/) + }) + it("forwards an explicit container override to DialogContent", () => { const container = document.createElement("div") diff --git a/packages/react/src/components/dialog-alike/common/types.ts b/packages/react/src/components/dialog-alike/common/types.ts index c68d237cb9..d78705961c 100644 --- a/packages/react/src/components/dialog-alike/common/types.ts +++ b/packages/react/src/components/dialog-alike/common/types.ts @@ -76,6 +76,11 @@ export type DialogAlikeInternalProps = { children: ReactNode // Disable the default padding from the dialog content area disableContentPadding?: boolean + /** + * Whether the dialog should have a full height. + * @default false + */ + fullHeight?: boolean /** * Override the DOM element the dialog is portaled into. By default center * dialogs portal to the top-level `#f0-overlay-root` (escaping app stacking diff --git a/packages/react/src/ui/F0Wizard/F0Wizard.tsx b/packages/react/src/ui/F0Wizard/F0Wizard.tsx index 4bf37caa41..5c7bfc8bfb 100644 --- a/packages/react/src/ui/F0Wizard/F0Wizard.tsx +++ b/packages/react/src/ui/F0Wizard/F0Wizard.tsx @@ -107,6 +107,7 @@ export const F0Wizard: FC = ({ primaryAction={primaryAction} secondaryAction={secondaryAction} disableContentPadding + fullHeight > = ({ steps={steps} allowStepSkipping={allowStepSkipping} > -
+
diff --git a/packages/react/src/ui/F0Wizard/__tests__/F0Wizard.test.tsx b/packages/react/src/ui/F0Wizard/__tests__/F0Wizard.test.tsx index d9f52fd90a..5df9397123 100644 --- a/packages/react/src/ui/F0Wizard/__tests__/F0Wizard.test.tsx +++ b/packages/react/src/ui/F0Wizard/__tests__/F0Wizard.test.tsx @@ -681,6 +681,19 @@ describe("F0Wizard", () => { expect(screen.getByText("Step 2 content")).toBeInTheDocument() }) + it("sizes the step content row to fill the dialog height, not a fixed viewport fraction", () => { + render( + {}} steps={makeSteps(2)}> + {() =>
Content
} +
+ ) + + const stepRow = document.querySelector(".flex.flex-1.flex-row") + expect(stepRow).not.toBeNull() + expect(stepRow).toHaveClass("h-full") + expect(stepRow?.className).not.toMatch(/h-\[/) + }) + it("does not skip steps when autoSkipCompletedSteps is disabled", () => { render(