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
48 changes: 47 additions & 1 deletion packages/components/src/Modal/Modal.rebuilt.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
--modal--max-height: calc(100dvh - 2 * var(--space-base));
--modal--margin: auto;
--modal--border-radius: var(--radius-base);
--modal--sticky-region-fade-size: var(--space-large);
--modal--padding-horizontal: var(--space-base);
--modal--padding-vertical: var(--space-base);
--modal--padding: var(--modal--padding-vertical)
Expand Down Expand Up @@ -52,6 +53,46 @@
flex: 0 0 auto;
}

.modalWithStickyRegions {
display: flex;
flex-direction: column;
}

.stickyHeaderRegion,
.stickyActionsRegion {
position: relative;
z-index: 1;
background-color: var(--color-surface);
}

.stickyHeaderRegion::after,
.stickyActionsRegion::before {
content: "";
position: absolute;
right: 0;
left: 0;
height: var(--modal--sticky-region-fade-size);
pointer-events: none;
}

.stickyHeaderRegion::after {
bottom: calc(var(--modal--sticky-region-fade-size) * -1);
background: linear-gradient(
to bottom,
var(--color-surface) 0%,
transparent 100%
);
}

.stickyActionsRegion::before {
top: calc(var(--modal--sticky-region-fade-size) * -1);
background: linear-gradient(
to top,
var(--color-surface) 0%,
transparent 100%
);
}

.modal:focus-visible {
box-shadow: var(--shadow-focus);
}
Expand All @@ -63,6 +104,11 @@
overflow-y: auto;
}

.modalWithStickyRegions .modalBody {
flex: 1 1 auto;
min-height: 0;
}

/* Adjust `Content` and `Tab` components public padding to match the modal */

.modalBody > * {
Expand Down Expand Up @@ -101,7 +147,7 @@
display: flex;
padding: var(--modal--padding);
padding-top: 0;
flex: 1 1 100%;
flex: 0 0 auto;
justify-content: flex-end;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/components/src/Modal/Modal.rebuilt.module.css.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ declare const styles: {
readonly "leftAction": string;
readonly "modal": string;
readonly "modalBody": string;
readonly "modalWithStickyRegions": string;
readonly "overlay": string;
readonly "overlayBackground": string;
readonly "rightAction": string;
readonly "stickyActionsRegion": string;
readonly "stickyHeaderRegion": string;
};
export = styles;

73 changes: 73 additions & 0 deletions packages/components/src/Modal/Modal.rebuilt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,79 @@ describe("Composable Modal", () => {
);
});

describe("sticky regions", () => {
it("should support sticky actions", () => {
render(
<Modal.Provider open={true}>
<Modal.Content>
<Modal.Header title="Modal Title" />
<Content>
<Text>Long content</Text>
</Content>
<Modal.Actions primary={{ label: "Submit" }} variant="sticky" />
</Modal.Content>
</Modal.Provider>,
);

const actions = screen.getByTestId("ATL-Modal-Actions");
const modalBody = document.querySelector(".modalBody");
expect(modalBody).not.toContainElement(actions);
});

it("should support sticky header", () => {
render(
<Modal.Provider open={true}>
<Modal.Content>
<Modal.Header title="Modal Title" variant="sticky" />
<Content>
<Text>Long content</Text>
</Content>
</Modal.Content>
</Modal.Provider>,
);

const header = screen.getByTestId(MODAL_HEADER_ID);
const modalBody = document.querySelector(".modalBody");
expect(modalBody).not.toContainElement(header);
});

it("keeps inline actions in the body when header is sticky", () => {
render(
<Modal.Provider open={true}>
<Modal.Content>
<Modal.Header title="Modal Title" variant="sticky" />
<Content>
<Text>Long content</Text>
</Content>
<Modal.Actions primary={{ label: "Submit" }} variant="inline" />
</Modal.Content>
</Modal.Provider>,
);

const actions = screen.getByTestId("ATL-Modal-Actions");
const modalBody = document.querySelector(".modalBody");
expect(modalBody).toContainElement(actions);
});

it("keeps inline header in the body when actions are sticky", () => {
render(
<Modal.Provider open={true}>
<Modal.Content>
<Modal.Header title="Modal Title" />
<Content>
<Text>Long content</Text>
</Content>
<Modal.Actions primary={{ label: "Submit" }} variant="sticky" />
</Modal.Content>
</Modal.Provider>,
);

const header = screen.getByTestId(MODAL_HEADER_ID);
const modalBody = document.querySelector(".modalBody");
expect(modalBody).toContainElement(header);
});
});

it("should allow overriding of action buttons", () => {
render(
<Modal.Provider open={true}>
Expand Down
53 changes: 51 additions & 2 deletions packages/components/src/Modal/Modal.rebuilt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ export function ModalContent({ children }: ModalContainerProps) {
startedInsideRef,
} = useModalContext();
const { modal } = useModalStyles(size);
const childrenArray = React.Children.toArray(children);
const { sticky: stickyHeaders, rest: afterHeaders } =
extractSticky<HeaderProps>(
childrenArray,
ModalHeader,
props => props.variant === "sticky",
);
const { sticky: stickyActions, rest: bodyChildren } =
extractSticky<ModalActionsProps>(
afterHeaders,
ModalActions,
props => props.variant === "sticky",
);
const hasStickyRegions = stickyHeaders.length > 0 || stickyActions.length > 0;

return (
<AnimatePresence>
Expand All @@ -155,7 +169,9 @@ export function ModalContent({ children }: ModalContainerProps) {
data-modal-node-id={floatingNodeId}
{...getFloatingProps({
role: "dialog",
className: modal,
className: hasStickyRegions
? `${modal} ${styles.modalWithStickyRegions}`
: modal,
"aria-labelledby": modalLabelledBy,
"aria-label": ariaLabel,
"aria-modal": true,
Expand All @@ -165,9 +181,19 @@ export function ModalContent({ children }: ModalContainerProps) {
if (startedInsideRef) startedInsideRef.current = true;
}}
>
{stickyHeaders.length > 0 && (
<div className={styles.stickyHeaderRegion}>
{stickyHeaders}
</div>
)}
<div className={styles.modalBody} tabIndex={-1}>
{children}
{bodyChildren}
</div>
{stickyActions.length > 0 && (
<div className={styles.stickyActionsRegion}>
{stickyActions}
</div>
)}
</motion.div>
</FloatingFocusManager>
</ModalOverlay>
Expand All @@ -178,3 +204,26 @@ export function ModalContent({ children }: ModalContainerProps) {
</AnimatePresence>
);
}

function extractSticky<T>(
children: React.ReactNode[],
componentType: React.ElementType,
match: (props: T) => boolean,
) {
const sticky: React.ReactElement[] = [];
const rest = children.filter(child => {
if (
React.isValidElement<T>(child) &&
child.type === componentType &&
match(child.props)
) {
sticky.push(child);

return false;
}

return true;
});

return { sticky, rest };
}
97 changes: 97 additions & 0 deletions packages/components/src/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,103 @@ export const ActionTypes: Story = {
},
};

const StickyRegionsTemplate = () => {
const [stickyRegionsOpen, setStickyRegionsOpen] = useState(false);
const [stickyHeaderOnlyOpen, setStickyHeaderOnlyOpen] = useState(false);
const [stickyActionsOnlyOpen, setStickyActionsOnlyOpen] = useState(false);
const paragraphs = Array.from({ length: 14 }, (_, i) => (
<Text key={i}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. ({i + 1})
</Text>
));

return (
<>
<Flex gap="small" template={["shrink", "shrink", "shrink"]}>
<Button
label="Open Sticky Regions"
onClick={() => setStickyRegionsOpen(true)}
/>
<Button
label="Open Sticky Header Only"
onClick={() => setStickyHeaderOnlyOpen(true)}
/>
<Button
label="Open Sticky Actions Only"
onClick={() => setStickyActionsOnlyOpen(true)}
/>
</Flex>

<Modal.Provider
open={stickyRegionsOpen}
onRequestClose={() => setStickyRegionsOpen(false)}
>
<Modal.Content>
<Modal.Header title="Sticky Regions" variant="sticky" />
<Content>{paragraphs}</Content>
<Modal.Actions
variant="sticky"
primary={{
label: "Done",
onClick: () => setStickyRegionsOpen(false),
}}
secondary={{
label: "Cancel",
onClick: () => setStickyRegionsOpen(false),
}}
/>
</Modal.Content>
</Modal.Provider>

<Modal.Provider
open={stickyHeaderOnlyOpen}
onRequestClose={() => setStickyHeaderOnlyOpen(false)}
>
<Modal.Content>
<Modal.Header title="Sticky Header Only" variant="sticky" />
<Content>{paragraphs}</Content>
<Modal.Actions
primary={{
label: "Done",
onClick: () => setStickyHeaderOnlyOpen(false),
}}
secondary={{
label: "Cancel",
onClick: () => setStickyHeaderOnlyOpen(false),
}}
/>
</Modal.Content>
</Modal.Provider>

<Modal.Provider
open={stickyActionsOnlyOpen}
onRequestClose={() => setStickyActionsOnlyOpen(false)}
>
<Modal.Content>
<Modal.Header title="Sticky Actions Only" />
<Content>{paragraphs}</Content>
<Modal.Actions
variant="sticky"
primary={{
label: "Done",
onClick: () => setStickyActionsOnlyOpen(false),
}}
secondary={{
label: "Cancel",
onClick: () => setStickyActionsOnlyOpen(false),
}}
/>
</Modal.Content>
</Modal.Provider>
</>
);
};

export const StickyRegions: Story = {
render: StickyRegionsTemplate,
};

function CustomHeader() {
const { header } = useModalStyles();
const { onRequestClose } = useModalContext();
Expand Down
24 changes: 23 additions & 1 deletion packages/components/src/Modal/Modal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,27 @@ export interface ModalActionsProps {
* Useful for actions like "Cancel" that are not destructive.
*/
readonly tertiary?: ButtonProps;
/**
* Controls how the actions are positioned within the modal content.
* - "inline": actions flow with content
* - "sticky": actions stay visible at the bottom while content scrolls
* @default "inline"
*/
readonly variant?: "inline" | "sticky";
}

interface HeaderPropsWithoutChildren {
/**
* Title of the modal.
*/
readonly title: string;
/**
* Controls how the header is positioned within the modal content.
* - "inline": header flows with content
* - "sticky": header stays visible while content scrolls
* @default "inline"
*/
readonly variant?: "inline" | "sticky";
/**
* Whether the modal is dismissible.
*/
Expand All @@ -102,7 +116,15 @@ interface HeaderPropsWithoutChildren {
*/
onRequestClose?(): void;
}
type HeaderWithChildren = PropsWithChildren;
type HeaderWithChildren = PropsWithChildren<{
/**
* Controls how the header is positioned within the modal content.
* - "inline": header flows with content
* - "sticky": header stays visible while content scrolls
* @default "inline"
*/
readonly variant?: "inline" | "sticky";
}>;

export type HeaderProps = XOR<HeaderPropsWithoutChildren, HeaderWithChildren>;

Expand Down
Loading
Loading