diff --git a/.changeset/giant-hats-clean.md b/.changeset/giant-hats-clean.md
new file mode 100644
index 00000000..e7ebba98
--- /dev/null
+++ b/.changeset/giant-hats-clean.md
@@ -0,0 +1,5 @@
+---
+"@hopper-ui/components": patch
+---
+
+Add Modal and CustomModal component
diff --git a/.stylelintrc.js b/.stylelintrc.js
index df13b07b..1a7241e6 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -20,6 +20,7 @@ const config = {
"prettier/prettier": null,
// We want to enforce the use of logical properties
"csstools/use-logical": true,
+ "media-feature-range-notation": "prefix",
"selector-class-pattern": [
/** Selector that ensures our classNames have the pattern hop-ComponentName__element-name--modifier-name */
"^hop-([A-Z][A-z0-9]+)([-]?[a-z0-9]+)*(__[a-z0-9]([-]?[a-z0-9]+)*)?(--[a-z0-9]([-]?[a-z0-9]+)*)?$",
diff --git a/apps/docs/content/components/overlays/Modal.mdx b/apps/docs/content/components/overlays/Modal.mdx
new file mode 100644
index 00000000..9bb1aa63
--- /dev/null
+++ b/apps/docs/content/components/overlays/Modal.mdx
@@ -0,0 +1,110 @@
+---
+title: Modal
+description: Modals focus the user’s attention exclusively on one task or piece of information via a window that sits on top of the page content.
+category: "overlays"
+links:
+ source: https://github.com/gsoft-inc/wl-hopper/blob/main/packages/components/src/Modal/src/Modal.tsx
+---
+
+
+
+### Composed Components
+
+A `Modal` uses the following components:
+
+
+
+## Usage
+
+### Default
+
+A modal must have an heading and a content.
+
+
+
+### Image
+
+A modal can have a side banner image. Make sure the image has no essential information as it could be cropped in mobile view. Images should not prevent a user from seeing the close button, be conscious of this.
+
+
+
+### Choice
+
+A modal can offer a choice between 2 options. Keep the copy not too long in order to help the user quickly make his choice.
+
+
+
+### Header
+
+Use an header to provide additional information usually in the form of a link or a tooltip that provides more context to the task at hand. Links should open in a new window.
+
+
+
+### Footer
+
+Use a footer to provide trivial information about content present in the modal, like a step : 1/3.
+
+
+
+### Buttons
+
+A modal can have a single button. Use a primary button to provide the main action.
+
+
+
+Or a group of button. A maximum of 3 buttons are allowed in a modal, when necessary. The secondary and tertiary actions should be using a secondary variant.
+
+
+
+### Dismissable
+
+By default, a modal will dismiss on outside interactions and esc keydown. However, in some cases, you might want to force the user to explicitly dismiss the modal with a targeted call to action.
+This is what the `isDismissable` and the `isKeyboardDismissDisabled` prop is for.
+
+You can set the isDismissable prop to false and isKeyboardDismissDisabled to true and render a call to action which will manually dismiss the popover by calling a close function.
+
+
+
+### Controlled
+
+The open state can be handled in controlled mode.
+
+
+
+### Custom trigger
+
+You don't have to use a ModalTrigger component if it doesn't fit your needs. A modal component can be used on it's own with any custom trigger.
+
+
+
+### Sizes
+
+A modal can be small, medium, large, extra-large, fullscreen or fullscreenTakeover. The default size is medium.
+
+
+
+### Responsive sizes
+
+A modal can have different size in mobile and desktop view.
+
+
+
+### Custom
+
+A CustomModal is a Modal with a custom layout. A CustomModal must contain a `` or have an aria-label or aria-labelledby attribute for accessibility.
+
+
+
+## Props
+
+### Modal
+
+
+
+### ModalTrigger
+
+
+
+### CustomModal
+
+
diff --git a/apps/docs/examples/Preview.ts b/apps/docs/examples/Preview.ts
index 92486510..e19f577e 100644
--- a/apps/docs/examples/Preview.ts
+++ b/apps/docs/examples/Preview.ts
@@ -995,6 +995,48 @@ export const Previews: Record = {
"Link/docs/image": {
component: lazy(() => import("@/../../packages/components/src/Link/docs/image.tsx"))
},
+ "Modal/docs/preview": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/preview.tsx"))
+ },
+ "Modal/docs/default": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/default.tsx"))
+ },
+ "Modal/docs/image": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/image.tsx"))
+ },
+ "Modal/docs/choice": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/choice.tsx"))
+ },
+ "Modal/docs/header": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/header.tsx"))
+ },
+ "Modal/docs/footer": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/footer.tsx"))
+ },
+ "Modal/docs/button": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/button.tsx"))
+ },
+ "Modal/docs/button-group": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/button-group.tsx"))
+ },
+ "Modal/docs/dismissable": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/dismissable.tsx"))
+ },
+ "Modal/docs/controlled": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/controlled.tsx"))
+ },
+ "Modal/docs/custom-trigger": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/custom-trigger.tsx"))
+ },
+ "Modal/docs/sizes": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/sizes.tsx"))
+ },
+ "Modal/docs/responsive-sizes": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/responsive-sizes.tsx"))
+ },
+ "Modal/docs/custom": {
+ component: lazy(() => import("@/../../packages/components/src/Modal/docs/custom.tsx"))
+ },
"overlays/Popover/docs/preview": {
component: lazy(() => import("@/../../packages/components/src/overlays/Popover/docs/preview.tsx"))
},
diff --git a/apps/docs/public/mossy-frog.jpg b/apps/docs/public/mossy-frog.jpg
new file mode 100644
index 00000000..cf63ca02
Binary files /dev/null and b/apps/docs/public/mossy-frog.jpg differ
diff --git a/packages/components/src/Modal/docs/button-group.tsx b/packages/components/src/Modal/docs/button-group.tsx
new file mode 100644
index 00000000..fc5a10dd
--- /dev/null
+++ b/packages/components/src/Modal/docs/button-group.tsx
@@ -0,0 +1,23 @@
+import { Button, ButtonGroup, Content, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/button.tsx b/packages/components/src/Modal/docs/button.tsx
new file mode 100644
index 00000000..c3c5440d
--- /dev/null
+++ b/packages/components/src/Modal/docs/button.tsx
@@ -0,0 +1,22 @@
+import { Button, Content, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/choice.tsx b/packages/components/src/Modal/docs/choice.tsx
new file mode 100644
index 00000000..1bb8354e
--- /dev/null
+++ b/packages/components/src/Modal/docs/choice.tsx
@@ -0,0 +1,40 @@
+import { Button, Card, Content, Flex, Heading, Image, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+
+
+
+
+
+ Frog
+
+ Common frogs are found in ponds, marshes, and forests across the world. Unlike some of their flashier cousins, they rely on stealth and speed rather than bright colors to survive.
+
+
+
+
+
+
+
+
+
+ Mossy Frog
+
+ A mossy tree frog with rough, bark-like skin, blending perfectly into its surroundings for camouflage and protection.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/controlled.tsx b/packages/components/src/Modal/docs/controlled.tsx
new file mode 100644
index 00000000..631d47f0
--- /dev/null
+++ b/packages/components/src/Modal/docs/controlled.tsx
@@ -0,0 +1,18 @@
+import { Button, Content, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+import { useState } from "react";
+
+export default function Example() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/custom-trigger.tsx b/packages/components/src/Modal/docs/custom-trigger.tsx
new file mode 100644
index 00000000..2796a61e
--- /dev/null
+++ b/packages/components/src/Modal/docs/custom-trigger.tsx
@@ -0,0 +1,18 @@
+import { Button, Content, Heading, Modal } from "@hopper-ui/components";
+import { useState } from "react";
+
+export default function Example() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ >
+ );
+}
diff --git a/packages/components/src/Modal/docs/custom.tsx b/packages/components/src/Modal/docs/custom.tsx
new file mode 100644
index 00000000..abc2370f
--- /dev/null
+++ b/packages/components/src/Modal/docs/custom.tsx
@@ -0,0 +1,21 @@
+import { Button, Content, CustomModal, Heading, Image, ModalTrigger, Stack } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ {({ close }) => (
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/default.tsx b/packages/components/src/Modal/docs/default.tsx
new file mode 100644
index 00000000..e0400863
--- /dev/null
+++ b/packages/components/src/Modal/docs/default.tsx
@@ -0,0 +1,15 @@
+import { Button, Content, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/dismissable.tsx b/packages/components/src/Modal/docs/dismissable.tsx
new file mode 100644
index 00000000..29a81a8b
--- /dev/null
+++ b/packages/components/src/Modal/docs/dismissable.tsx
@@ -0,0 +1,22 @@
+import { Button, Content, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/footer.tsx b/packages/components/src/Modal/docs/footer.tsx
new file mode 100644
index 00000000..986602e3
--- /dev/null
+++ b/packages/components/src/Modal/docs/footer.tsx
@@ -0,0 +1,18 @@
+import { Button, Content, Footer, Heading, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/header.tsx b/packages/components/src/Modal/docs/header.tsx
new file mode 100644
index 00000000..97af42b9
--- /dev/null
+++ b/packages/components/src/Modal/docs/header.tsx
@@ -0,0 +1,24 @@
+import { Button, Content, Header, Heading, Link, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+
+ Wikipedia
+
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/image.tsx b/packages/components/src/Modal/docs/image.tsx
new file mode 100644
index 00000000..ea0012de
--- /dev/null
+++ b/packages/components/src/Modal/docs/image.tsx
@@ -0,0 +1,16 @@
+import { Button, Content, Heading, Image, Modal, ModalTrigger } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/preview.tsx b/packages/components/src/Modal/docs/preview.tsx
new file mode 100644
index 00000000..3956fe87
--- /dev/null
+++ b/packages/components/src/Modal/docs/preview.tsx
@@ -0,0 +1,20 @@
+import { Button, Content, Heading, Modal, ModalTrigger, Text } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ Frogs don’t drink water with their mouths! Instead, they absorb moisture through their specialized skin patch on their belly and thighs.
+
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/responsive-sizes.tsx b/packages/components/src/Modal/docs/responsive-sizes.tsx
new file mode 100644
index 00000000..24a779ce
--- /dev/null
+++ b/packages/components/src/Modal/docs/responsive-sizes.tsx
@@ -0,0 +1,22 @@
+import { Button, Content, Heading, Modal, ModalTrigger, Stack } from "@hopper-ui/components";
+
+export default function Example() {
+ return (
+
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+ );
+}
diff --git a/packages/components/src/Modal/docs/sizes.tsx b/packages/components/src/Modal/docs/sizes.tsx
new file mode 100644
index 00000000..f812629c
--- /dev/null
+++ b/packages/components/src/Modal/docs/sizes.tsx
@@ -0,0 +1,26 @@
+import { Button, Content, Heading, Modal, type ModalProps, ModalTrigger, Stack } from "@hopper-ui/components";
+
+export default function Example() {
+ const modal = (size: ModalProps["size"]) => (
+
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ );
+
+ return (
+
+ {modal("sm")}
+ {modal("md")}
+ {modal("lg")}
+ {modal("xl")}
+ {modal("fullscreen")}
+ {modal("fullscreenTakeover")}
+
+ );
+}
diff --git a/packages/components/src/Modal/index.ts b/packages/components/src/Modal/index.ts
new file mode 100644
index 00000000..401c73ac
--- /dev/null
+++ b/packages/components/src/Modal/index.ts
@@ -0,0 +1 @@
+export * from "./src/index.ts";
diff --git a/packages/components/src/Modal/src/BaseModal.module.css b/packages/components/src/Modal/src/BaseModal.module.css
new file mode 100644
index 00000000..96007bc3
--- /dev/null
+++ b/packages/components/src/Modal/src/BaseModal.module.css
@@ -0,0 +1,155 @@
+.hop-BaseModal {
+ --hop-BaseModal-isolation: isolate;
+ --hop-BaseModal-position: fixed;
+ --hop-BaseModal-inset: 0;
+ --hop-BaseModal-inset-block-start: 0;
+ --hop-BaseModal-inset-inline-start: 0;
+ --hop-BaseModal-overflow: hidden;
+ --hop-BaseModal-display: flex;
+ --hop-BaseModal-align-items: center;
+ --hop-BaseModal-justify-content: center;
+ --hop-BaseModal-inline-size: 100%;
+ --hop-BaseModal-block-size: 100%;
+ --hop-BaseModal-background-color: #3c3c3c99;
+ --hop-BaseModal-z-index: 10000;
+
+ /* Exiting */
+ --hop-basemodal-exiting-opacity: 0;
+
+ isolation: var(--hop-BaseModal-isolation);
+ position: var(--hop-BaseModal-position);
+ z-index: var(--hop-BaseModal-z-index);
+ inset: var(--hop-BaseModal-inset);
+ inset-block-start: var(--hop-BaseModal-inset-block-start);
+ inset-inline-start: var(--hop-BaseModal-inset-inline-start);
+
+ overflow: var(--hop-BaseModal-overflow);
+ display: var(--hop-BaseModal-display);
+ align-items: var(--hop-BaseModal-align-items);
+ justify-content: var(--hop-BaseModal-justify-content);
+
+ inline-size: var(--hop-BaseModal-inline-size);
+ block-size: var(--hop-BaseModal-block-size);
+
+ /* hop-rock-800 with a 60% transparency */
+ background-color: var(--hop-BaseModal-background-color);
+
+ transition: opacity var(--hop-easing-duration-2) var(--hop-easing-productive);
+}
+
+.hop-BaseModal__modal {
+ --hop-BaseModal__modal-display: flex;
+ --hop-BaseModal__modal-flex-direction: column;
+ --hop-BaseModal__modal-max-inline-size: 90vw;
+ --hop-BaseModal__modal-max-block-size: 90vh;
+ --hop-BaseModal__modal-background: var(--hop-neutral-surface);
+ --hop-BaseModal__modal-border-radius: var(--hop-shape-rounded-md);
+ --hop-BaseModal__modal-transition: opacity var(--hop-easing-duration-3) ease-in-out var(--hop-easing-duration-2), transform var(--hop-easing-duration-3) ease-in-out var(--hop-easing-duration-2);
+
+ /* Entering */
+ --hop-BaseModal__modal-entering-opacity: 0;
+ --hop-BaseModal__modal-entering-transform: translateY(1.25rem);
+
+ /* Exiting */
+ --hop-BaseModal__modal-exiting-opacity: 0;
+ --hop-BaseModal__modal-exiting-transition: opacity var(--hop-easing-duration-1) ease-in-out 0, transform var(--hop-easing-duration-1) ease-in-out 0;
+
+ /* Small */
+ --hop-BaseModal__modal-sm-width: 28.75rem;
+
+ /* Medium */
+ --hop-BaseModal__modal-md-width: 36.5rem;
+
+ /* Large */
+ --hop-BaseModal__modal-lg-width: 41.5rem;
+
+ /* Extra Large */
+ --hop-BaseModal__modal-xl-width: 50rem;
+
+ /* Fullscreen */
+ --hop-BaseModal__modal-fullscreen-max-width: none;
+ --hop-BaseModal__modal-fullscreen-max-height: none;
+ --hop-BaseModal__modal-fullscreen-width: calc(100vw - 2.5rem);
+ --hop-BaseModal__modal-fullscreen-height: calc(100vh - 2.5rem);
+
+ /* Fullscreentakeover */
+ --hop-BaseModal__modal-fullscreentakeover-width: 100vw;
+ --hop-BaseModal__modal-fullscreentakeover-height: 100vh;
+ --hop-BaseModal__modal-fullscreentakeover-border-radius: 0;
+
+ /* Internal variables */
+ --border-radius: var(--hop-BaseModal__modal-border-radius);
+ --max-inline-size: var(--hop-BaseModal__modal-max-inline-size);
+ --max-block-size: var(--hop-BaseModal__modal-max-block-size);
+ --image-size: 0rem;
+
+ display: var(--hop-BaseModal__modal-display);
+ flex-direction: var(--hop-BaseModal__modal-flex-direction);
+
+ inline-size: var(--width);
+ max-inline-size: var(--max-inline-size);
+ block-size: var(--height);
+ max-block-size: var(--max-block-size);
+
+ background: var(--hop-BaseModal__modal-background);
+ border-radius: var(--border-radius);
+
+ transition: var(--hop-BaseModal__modal-transition);
+}
+
+@media screen and (min-width: 48rem) {
+ .hop-BaseModal--image .hop-BaseModal__modal {
+ --image-size: 16rem;
+ }
+}
+
+.hop-BaseModal--entering {
+ opacity: var(--hop-BaseModal-entering-opacity);
+
+ .hop-BaseModal__modal {
+ transform: var(--hop-BaseModal__modal-entering-transform);
+ opacity: var(--hop-BaseModal__modal-entering-opacity);
+ }
+}
+
+.hop-BaseModal--exiting {
+ opacity: var(--hop-BaseModal-exiting-opacity);
+
+ .hop-BaseModal__modal {
+ opacity: var(--hop-BaseModal__modal-exiting-opacity);
+ transition: var(--hop-BaseModal__modal-exiting-transition);
+ }
+}
+
+.hop-BaseModal--sm .hop-BaseModal__modal {
+ --width: calc(var(--image-size) + var(--hop-BaseModal__modal-sm-width));
+}
+
+.hop-BaseModal--md .hop-BaseModal__modal {
+ --width: calc(var(--image-size) + var(--hop-BaseModal__modal-md-width));
+}
+
+.hop-BaseModal--lg .hop-BaseModal__modal {
+ --width: calc(var(--image-size) + var(--hop-BaseModal__modal-lg-width));
+}
+
+.hop-BaseModal--xl .hop-BaseModal__modal {
+ --width: calc(var(--image-size) + var(--hop-BaseModal__modal-xl-width));
+}
+
+.hop-BaseModal--fullscreen .hop-BaseModal__modal {
+ --width: var(--hop-BaseModal__modal-fullscreen-width);
+ --height: var(--hop-BaseModal__modal-fullscreen-height);
+}
+
+.hop-BaseModal--fullscreentakeover .hop-BaseModal__modal {
+ --width: var(--hop-BaseModal__modal-fullscreentakeover-width);
+ --height: var(--hop-BaseModal__modal-fullscreentakeover-height);
+ --border-radius: var(--hop-BaseModal__modal-fullscreentakeover-border-radius);
+}
+
+.hop-BaseModal--fullscreen .hop-BaseModal__modal,
+.hop-BaseModal--fullscreentakeover .hop-BaseModal__modal {
+ --max-inline-size: var(--hop-BaseModal__modal-fullscreen-max-width);
+ --max-block-size: var(--hop-BaseModal__modal-fullscreen-max-height);
+}
diff --git a/packages/components/src/Modal/src/BaseModal.tsx b/packages/components/src/Modal/src/BaseModal.tsx
new file mode 100644
index 00000000..04d3ea24
--- /dev/null
+++ b/packages/components/src/Modal/src/BaseModal.tsx
@@ -0,0 +1,86 @@
+import { type ResponsiveProp, type StyledComponentProps, useColorSchemeContext, useResponsiveValue, useStyledSystem } from "@hopper-ui/styled-system";
+import clsx from "clsx";
+import { type CSSProperties, type ForwardedRef, forwardRef } from "react";
+import { ModalOverlay, type ModalOverlayProps, type ModalRenderProps, Modal as RACModal, useContextProps } from "react-aria-components";
+
+import { HopperProvider } from "../../HopperProvider/index.ts";
+import { cssModule } from "../../utils/index.ts";
+
+import { BaseModalContext } from "./BaseModalContext.ts";
+
+import styles from "./BaseModal.module.css";
+
+export const GlobalBaseModalCssSelector = "hop-BaseModal";
+
+export interface BaseModalProps extends StyledComponentProps {
+ /**
+ * The size of the modal.
+ * @default "md"
+ */
+ size?: ResponsiveProp<"sm" | "md" | "lg" | "xl" | "fullscreen" | "fullscreenTakeover">;
+ /**
+ * Whether the modal has an image.
+ */
+ hasImage?: boolean;
+}
+
+const BaseModal = (props: BaseModalProps, ref: ForwardedRef) => {
+ [props, ref] = useContextProps(props, ref, BaseModalContext);
+ const { stylingProps, ...ownProps } = useStyledSystem(props);
+ const {
+ className,
+ style,
+ slot,
+ size: sizeProp,
+ children,
+ hasImage,
+ ...otherProps
+ } = ownProps;
+
+ const { colorScheme } = useColorSchemeContext();
+ const size = useResponsiveValue(sizeProp) ?? "md";
+
+ const classNames = (renderProps: ModalRenderProps) => clsx(
+ GlobalBaseModalCssSelector,
+ cssModule(
+ styles,
+ GlobalBaseModalCssSelector,
+ size.toLowerCase(),
+ renderProps.isEntering && "entering",
+ renderProps.isExiting && "exiting",
+ hasImage && "image"
+ ),
+ stylingProps.className,
+ className
+ );
+
+ const mergedStyles: CSSProperties = {
+ ...style,
+ ...stylingProps.style
+ };
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+/**
+ * A BaseModal is an overlay element which blocks interaction with elements outside it.
+ *
+ * [View Documentation](https://hopper.workleap.design/components/Modal)
+ */
+const _BaseModal = forwardRef(BaseModal);
+_BaseModal.displayName = "BaseModal";
+
+export { _BaseModal as BaseModal };
diff --git a/packages/components/src/Modal/src/BaseModalContext.ts b/packages/components/src/Modal/src/BaseModalContext.ts
new file mode 100644
index 00000000..8aa6a14a
--- /dev/null
+++ b/packages/components/src/Modal/src/BaseModalContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+import type { ContextValue } from "react-aria-components";
+
+import type { BaseModalProps } from "./BaseModal.tsx";
+
+export const BaseModalContext = createContext>({});
+
+BaseModalContext.displayName = "BaseModalContext";
diff --git a/packages/components/src/Modal/src/CustomModal.module.css b/packages/components/src/Modal/src/CustomModal.module.css
new file mode 100644
index 00000000..003b88cb
--- /dev/null
+++ b/packages/components/src/Modal/src/CustomModal.module.css
@@ -0,0 +1,16 @@
+.hop-CustomModal {
+ --hop-CustomModal-overflow: auto;
+ --hop-CustomModal-box-sizing: border-box;
+ --hop-CustomModal-outline: none;
+ --hop-CustomModal-color: var(--hop-neutral-text);
+
+ overflow: var(--hop-CustomModal-overflow);
+
+ box-sizing: var(--hop-CustomModal-box-sizing);
+ max-block-size: inherit;
+
+ color: var(--hop-CustomModal-color);
+
+ border-radius: inherit;
+ outline: var(--hop-CustomModal-outline);
+}
diff --git a/packages/components/src/Modal/src/CustomModal.tsx b/packages/components/src/Modal/src/CustomModal.tsx
new file mode 100644
index 00000000..b26f7bf6
--- /dev/null
+++ b/packages/components/src/Modal/src/CustomModal.tsx
@@ -0,0 +1,116 @@
+import { type ResponsiveProp, type StyledComponentProps, useResponsiveValue, useStyledSystem } from "@hopper-ui/styled-system";
+import clsx from "clsx";
+import { type CSSProperties, type ForwardedRef, forwardRef } from "react";
+import { composeRenderProps, Dialog, type DialogProps, DialogTrigger, type ModalOverlayProps, OverlayTriggerStateContext, useContextProps } from "react-aria-components";
+
+import { cssModule } from "../../utils/index.ts";
+
+import { BaseModal } from "./BaseModal.tsx";
+import { CustomModalContext } from "./CustomModalContext.ts";
+
+import styles from "./CustomModal.module.css";
+
+export const GlobalCustomModalCssSelector = "hop-CustomModal";
+
+export interface CustomModalProps extends
+ StyledComponentProps,
+ Pick {
+ /**
+ * Whether the CustomModal is dismissible.
+ * @default true
+ */
+ isDismissible?: boolean;
+ /**
+ * Whether pressing the escape key to close the dialog should be disabled.
+ */
+ isKeyboardDismissDisabled?: boolean;
+ /**
+ * The size of the CustomModal.
+ * @default "md"
+ */
+ size?: ResponsiveProp<"sm" | "md" | "lg" | "xl" | "fullscreen" | "fullscreenTakeover">;
+ /**
+ * The props of the overlay
+ */
+ overlayProps?: Partial;
+}
+
+const CustomModal = (props: CustomModalProps, ref: ForwardedRef) => {
+ [props, ref] = useContextProps(props, ref, CustomModalContext);
+ const { stylingProps, ...ownProps } = useStyledSystem(props);
+
+ const {
+ className,
+ style,
+ slot,
+ isDismissible = true,
+ isKeyboardDismissDisabled,
+ size: sizeProp,
+ overlayProps,
+ isOpen,
+ defaultOpen,
+ onOpenChange,
+ children: childrenProp,
+ ...otherProps
+ } = ownProps;
+
+ const size = useResponsiveValue(sizeProp) ?? "md";
+
+ const classNames = clsx(
+ GlobalCustomModalCssSelector,
+ cssModule(
+ styles,
+ GlobalCustomModalCssSelector,
+ size.toLowerCase()
+ ),
+ stylingProps.className,
+ className
+ );
+
+ const mergedStyles: CSSProperties = {
+ ...style,
+ ...stylingProps.style
+ };
+
+ const children = composeRenderProps(childrenProp, prev => {
+ return prev;
+ });
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * A CustomModal is a Modal with a custom layout.
+ *
+ * [View Documentation](https://hopper.workleap.design/components/Modal)
+ */
+const _CustomModal = forwardRef(CustomModal);
+_CustomModal.displayName = "CustomModal";
+
+export { _CustomModal as CustomModal };
+
+export const CustomModalTrigger = DialogTrigger;
diff --git a/packages/components/src/Modal/src/CustomModalContext.ts b/packages/components/src/Modal/src/CustomModalContext.ts
new file mode 100644
index 00000000..cf55d640
--- /dev/null
+++ b/packages/components/src/Modal/src/CustomModalContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+import type { ContextValue } from "react-aria-components";
+
+import type { CustomModalProps } from "./CustomModal.tsx";
+
+export const CustomModalContext = createContext>({});
+
+CustomModalContext.displayName = "CustomModalContext";
diff --git a/packages/components/src/Modal/src/Modal.module.css b/packages/components/src/Modal/src/Modal.module.css
new file mode 100644
index 00000000..4bb1a158
--- /dev/null
+++ b/packages/components/src/Modal/src/Modal.module.css
@@ -0,0 +1,150 @@
+.hop-Modal {
+ --hop-Modal-position: relative;
+ --hop-Modal-overflow: auto;
+ --hop-Modal-display: grid;
+ --hop-Modal-box-sizing: border-box;
+ --hop-Modal-outline: none;
+ --hop-Modal-margin: var(--hop-space-inset-lg);
+ --hop-Modal-color: var(--hop-neutral-text);
+ --hop-Modal-grid-template:
+ "image image image" 12rem
+ "heading heading header" auto
+ "content content content" auto
+ "footer footer buttons" auto
+ / auto auto auto;
+
+ /* Large screen */
+ --hop-Modal-large-grid-template:
+ "image heading header" auto
+ "image content content" auto
+ "image footer buttons" auto
+ / 16rem auto auto;
+
+ /* Internal variables */
+ --grid-template: var(--hop-Modal-grid-template);
+
+ position: var(--hop-Modal-position);
+
+ overflow: var(--hop-Modal-overflow);
+ display: var(--hop-Modal-display);
+ grid-template: var(--grid-template);
+
+ box-sizing: var(--hop-Modal-box-sizing);
+ max-block-size: inherit;
+
+ color: var(--hop-Modal-color);
+
+ border-radius: inherit;
+ outline: var(--hop-Modal-outline);
+}
+
+.hop-Modal:not(.hop-Modal--fullscreen, .hop-Modal--fullscreentakeover) {
+ @media screen and (min-width: 49rem) {
+ --grid-template: var(--hop-Modal-large-grid-template);
+ }
+}
+
+.hop-Modal:not(:has(> .hop-Modal__image)) {
+ --grid-template:
+ "heading heading header" auto
+ "content content content" auto
+ "footer footer buttons" auto
+ / auto auto auto;
+
+ :not(.hop-Modal--fullscreen),
+ :not(.hop-Modal--fullscreentakeover) {
+ @media screen and (min-width: 49rem) {
+ --grid-template:
+ "heading header" auto
+ "content content" auto
+ "footer buttons" auto
+ / auto auto;
+ }
+ }
+}
+
+.hop-Modal__close {
+ position: absolute;
+ inset-block-start: var(--hop-Modal-margin);
+ inset-inline-end: var(--hop-Modal-margin);;
+}
+
+.hop-Modal > img.hop-Modal__image {
+ --hop-Modal-image-inline-size: 100%;
+ --hop-Modal-image-block-size: 100%;
+ --hop-Modal-image-object-fit: cover;
+
+ grid-area: image;
+ inline-size: var(--hop-Modal-image-inline-size);
+ block-size: var(--hop-Modal-image-block-size);
+ object-fit: var(--hop-Modal-image-object-fit);
+}
+
+.hop-Modal > .hop-Modal__heading {
+ grid-area: heading;
+ margin-block-start: var(--hop-Modal-margin);
+ margin-inline: var(--hop-Modal-margin);
+}
+
+.hop-Modal > .hop-Modal__header {
+ grid-area: header;
+ place-self: center end;
+ margin-block-start: var(--hop-Modal-margin);
+ margin-inline: var(--hop-Modal-margin);
+}
+
+.hop-Modal > .hop-Modal__content {
+ overflow-y: auto;
+ grid-area: content;
+ margin: var(--hop-Modal-margin);
+
+ @media (max-height: 24rem) {
+ overflow-y: visible;
+ }
+}
+
+.hop-Modal > .hop-Modal__footer {
+ grid-area: footer;
+ align-self: center;
+ margin-block-end: var(--hop-Modal-margin);
+ margin-inline: var(--hop-Modal-margin);
+}
+
+.hop-Modal > .hop-Modal__button {
+ grid-area: buttons;
+ place-self: center end;
+ margin-block-end: var(--hop-Modal-margin);
+ margin-inline: var(--hop-Modal-margin);
+}
+
+.hop-Modal > div.hop-Modal__button-group {
+ grid-area: buttons;
+ flex-wrap: nowrap;
+ place-self: center end;
+
+ margin-block-end: var(--hop-Modal-margin);
+ margin-inline: var(--hop-Modal-margin);
+}
+
+/* If there's a close button, add right margin to the header and heading */
+.hop-Modal:has(.hop-Modal__close) > .hop-Modal__header,
+.hop-Modal:has(.hop-Modal__close) > .hop-Modal__heading {
+ margin-inline-end: calc(var(--hop-Modal-margin) + 2rem);
+}
+
+/* On a small screen, if there's an image and a close button, remove the right margin */
+@media screen and (max-width: 48rem) {
+ .hop-Modal:has(.hop-Modal__close):has(> .hop-Modal__image) > .hop-Modal__header,
+ .hop-Modal:has(.hop-Modal__close):has(> .hop-Modal__image) > .hop-Modal__heading {
+ margin-inline-end: var(--hop-Modal-margin);
+ }
+}
+
+.hop-Modal:has(> .hop-Modal__header) > .hop-Modal__heading {
+ margin-inline-end: 0;
+}
+
+.hop-Modal:has(> .hop-Modal__button) > .hop-Modal__footer,
+.hop-Modal:has(> .hop-Modal__button-group) > .hop-Modal__footer {
+ margin-inline-end: 0;
+}
diff --git a/packages/components/src/Modal/src/Modal.tsx b/packages/components/src/Modal/src/Modal.tsx
new file mode 100644
index 00000000..a041e50b
--- /dev/null
+++ b/packages/components/src/Modal/src/Modal.tsx
@@ -0,0 +1,149 @@
+import { type ResponsiveProp, type StyledComponentProps, useResponsiveValue, useStyledSystem } from "@hopper-ui/styled-system";
+import clsx from "clsx";
+import { type CSSProperties, type ForwardedRef, forwardRef } from "react";
+import { composeRenderProps, Dialog, type DialogProps, type ModalOverlayProps, OverlayTriggerStateContext, Provider, useContextProps } from "react-aria-components";
+
+import { ButtonContext, ButtonGroupContext, CloseButton } from "../../buttons/index.ts";
+import { HeaderContext } from "../../Header/index.ts";
+import { ImageContext } from "../../Image/index.ts";
+import { ContentContext, FooterContext } from "../../layout/index.ts";
+import { HeadingContext } from "../../typography/index.ts";
+import { cssModule, useSlot } from "../../utils/index.ts";
+
+import { BaseModal } from "./BaseModal.tsx";
+import { ModalContext } from "./ModalContext.ts";
+
+import styles from "./Modal.module.css";
+
+export const GlobalModalCssSelector = "hop-Modal";
+
+export interface ModalProps extends
+ StyledComponentProps,
+ Pick {
+ /**
+ * Whether the Modal is dismissible.
+ * @default true
+ */
+ isDismissible?: boolean;
+ /**
+ * Whether pressing the escape key to close the dialog should be disabled.
+ */
+ isKeyboardDismissDisabled?: boolean;
+ /**
+ * The size of the modal.
+ * @default "md"
+ */
+ size?: ResponsiveProp<"sm" | "md" | "lg" | "xl" | "fullscreen" | "fullscreenTakeover">;
+ /**
+ * The props of the overlay
+ */
+ overlayProps?: Partial;
+}
+
+const Modal = (props: ModalProps, ref: ForwardedRef) => {
+ [props, ref] = useContextProps(props, ref, ModalContext);
+ const { stylingProps, ...ownProps } = useStyledSystem(props);
+ const [imageRef, hasImage] = useSlot();
+ const {
+ className,
+ style,
+ slot,
+ isDismissible = true,
+ isKeyboardDismissDisabled,
+ size: sizeProp,
+ children: childrenProp,
+ isOpen,
+ defaultOpen,
+ onOpenChange,
+ overlayProps,
+ ...otherProps
+ } = ownProps;
+
+ const size = useResponsiveValue(sizeProp) ?? "md";
+
+ const classNames = clsx(
+ GlobalModalCssSelector,
+ cssModule(
+ styles,
+ GlobalModalCssSelector,
+ size.toLowerCase()
+ ),
+ stylingProps.className,
+ className
+ );
+
+ const mergedStyles: CSSProperties = {
+ ...style,
+ ...stylingProps.style
+ };
+
+ const children = composeRenderProps(childrenProp, prev => {
+ return prev;
+ });
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * Modals focus the user’s attention exclusively on one task or piece of information via a window that sits on top of the page content.
+ *
+ * [View Documentation](https://hopper.workleap.design/components/Modal)
+ */
+const _Modal = forwardRef(Modal);
+_Modal.displayName = "Modal";
+
+export { _Modal as Modal };
diff --git a/packages/components/src/Modal/src/ModalContext.ts b/packages/components/src/Modal/src/ModalContext.ts
new file mode 100644
index 00000000..0fb435e6
--- /dev/null
+++ b/packages/components/src/Modal/src/ModalContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+import type { ContextValue } from "react-aria-components";
+
+import type { ModalProps } from "./Modal.tsx";
+
+export const ModalContext = createContext>({});
+
+ModalContext.displayName = "ModalContext";
diff --git a/packages/components/src/Modal/src/ModalTrigger.tsx b/packages/components/src/Modal/src/ModalTrigger.tsx
new file mode 100644
index 00000000..04f6d22e
--- /dev/null
+++ b/packages/components/src/Modal/src/ModalTrigger.tsx
@@ -0,0 +1,3 @@
+import { DialogTrigger } from "react-aria-components";
+
+export const ModalTrigger = DialogTrigger;
diff --git a/packages/components/src/Modal/src/index.ts b/packages/components/src/Modal/src/index.ts
new file mode 100644
index 00000000..86ad7506
--- /dev/null
+++ b/packages/components/src/Modal/src/index.ts
@@ -0,0 +1,6 @@
+export * from "./CustomModal.tsx";
+export * from "./CustomModalContext.ts";
+export * from "./Modal.tsx";
+export * from "./ModalContext.ts";
+export * from "./ModalTrigger.tsx";
+
diff --git a/packages/components/src/Modal/tests/assets/frog.jpg b/packages/components/src/Modal/tests/assets/frog.jpg
new file mode 100644
index 00000000..333b615f
Binary files /dev/null and b/packages/components/src/Modal/tests/assets/frog.jpg differ
diff --git a/packages/components/src/Modal/tests/assets/index.ts b/packages/components/src/Modal/tests/assets/index.ts
new file mode 100644
index 00000000..9f2ed6e7
--- /dev/null
+++ b/packages/components/src/Modal/tests/assets/index.ts
@@ -0,0 +1,4 @@
+import Frog from "./frog.jpg";
+import MossyFrog from "./mossy-frog.jpg";
+
+export { Frog, MossyFrog };
diff --git a/packages/components/src/Modal/tests/assets/mossy-frog.jpg b/packages/components/src/Modal/tests/assets/mossy-frog.jpg
new file mode 100644
index 00000000..cf63ca02
Binary files /dev/null and b/packages/components/src/Modal/tests/assets/mossy-frog.jpg differ
diff --git a/packages/components/src/Modal/tests/chromatic/CustomModal-dark.stories.tsx b/packages/components/src/Modal/tests/chromatic/CustomModal-dark.stories.tsx
new file mode 100644
index 00000000..bc41c97a
--- /dev/null
+++ b/packages/components/src/Modal/tests/chromatic/CustomModal-dark.stories.tsx
@@ -0,0 +1,85 @@
+import {
+ CloseButton,
+ Content,
+ CustomModal,
+ Div,
+ Heading,
+ Text
+} from "@hopper-ui/components";
+import { hopperParameters } from "@hopper-ui/storybook-addon";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta = {
+ title: "Components/Modal/CustomModal/dark",
+ component: CustomModal,
+ parameters: {
+ ...hopperParameters({ colorSchemes: ["dark"] })
+ },
+ decorators: [
+ Story => (
+
+
+
+ )
+ ],
+ args: {
+ isOpen: true,
+ padding: "inset-lg",
+ position: "relative"
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {
+ render: args => (
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ )
+} satisfies Story;
+
+export const Small = {
+ ...Default,
+ args: {
+ size: "sm"
+ }
+};
+
+export const Large = {
+ ...Default,
+ args: {
+ size: "lg"
+ }
+};
+
+export const ExtraLarge = {
+ ...Default,
+ args: {
+ size: "xl"
+ }
+};
+
+export const FullScreen = {
+ ...Default,
+ args: {
+ size: "fullscreen"
+ }
+};
+
+export const FullscreenTakeover = {
+ ...Default,
+ args: {
+ size: "fullscreenTakeover"
+ }
+};
diff --git a/packages/components/src/Modal/tests/chromatic/CustomModal-light.stories.tsx b/packages/components/src/Modal/tests/chromatic/CustomModal-light.stories.tsx
new file mode 100644
index 00000000..46018980
--- /dev/null
+++ b/packages/components/src/Modal/tests/chromatic/CustomModal-light.stories.tsx
@@ -0,0 +1,85 @@
+import {
+ CloseButton,
+ Content,
+ CustomModal,
+ Div,
+ Heading,
+ Text
+} from "@hopper-ui/components";
+import { hopperParameters } from "@hopper-ui/storybook-addon";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta = {
+ title: "Components/Modal/CustomModal/light",
+ component: CustomModal,
+ parameters: {
+ ...hopperParameters({ colorSchemes: ["light"] })
+ },
+ decorators: [
+ Story => (
+
+
+
+ )
+ ],
+ args: {
+ isOpen: true,
+ padding: "inset-lg",
+ position: "relative"
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {
+ render: args => (
+
+
+ Fascinating Frog Facts!
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+ )
+} satisfies Story;
+
+export const Small = {
+ ...Default,
+ args: {
+ size: "sm"
+ }
+};
+
+export const Large = {
+ ...Default,
+ args: {
+ size: "lg"
+ }
+};
+
+export const ExtraLarge = {
+ ...Default,
+ args: {
+ size: "xl"
+ }
+};
+
+export const FullScreen = {
+ ...Default,
+ args: {
+ size: "fullscreen"
+ }
+};
+
+export const FullscreenTakeover = {
+ ...Default,
+ args: {
+ size: "fullscreenTakeover"
+ }
+};
diff --git a/packages/components/src/Modal/tests/chromatic/Modal-dark.stories.tsx b/packages/components/src/Modal/tests/chromatic/Modal-dark.stories.tsx
new file mode 100644
index 00000000..621fb8b5
--- /dev/null
+++ b/packages/components/src/Modal/tests/chromatic/Modal-dark.stories.tsx
@@ -0,0 +1,244 @@
+import {
+ Button,
+ ButtonGroup,
+ Card,
+ Content,
+ Div,
+ Flex,
+ Footer,
+ Header,
+ Heading,
+ Image,
+ Modal,
+ Text
+} from "@hopper-ui/components";
+import { hopperParameters } from "@hopper-ui/storybook-addon";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Frog, MossyFrog } from "../assets/index.ts";
+
+const meta = {
+ title: "Components/Modal/dark",
+ component: Modal,
+ parameters: {
+ ...hopperParameters({ colorSchemes: ["dark"] })
+ },
+ decorators: [
+ Story => (
+
+
+
+ )
+ ],
+ args: {
+ isOpen: true
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const image = {
+ render: args => (
+
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const choice = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+
+
+
+
+ Frog
+
+ Common frogs are found in ponds, marshes, and forests across the world. Unlike some of their flashier cousins, they rely on stealth and speed rather than bright colors to survive.
+
+
+
+
+
+
+
+
+
+ Mossy Frog
+
+ A mossy tree frog with rough, bark-like skin, blending perfectly into its surroundings for camouflage and protection.
+
+
+
+
+
+
+
+
+ )
+} satisfies Story;
+
+export const header = {
+ render: args => (
+
+ Fascinating Frog Facts!
+ Nature’s Little Acrobats
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const footer = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+ )
+} satisfies Story;
+
+export const button = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const buttonGroup = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const NonDismissable = {
+ ...Default,
+ args: {
+ isDismissible: false
+ }
+} satisfies Story;
+
+export const everything = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+
+ Fascinating Frog Facts!
+ Nature’s Little Acrobats
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const Small = {
+ ...everything,
+ args: {
+ size: "sm"
+ }
+};
+
+export const Large = {
+ ...everything,
+ args: {
+ size: "lg"
+ }
+};
+
+export const ExtraLarge = {
+ ...everything,
+ args: {
+ size: "xl"
+ }
+};
+
+export const Fullscreen = {
+ ...everything,
+ args: {
+ size: "fullscreen"
+ }
+};
+
+export const FullscreenTakeover = {
+ ...everything,
+ args: {
+ size: "fullscreenTakeover"
+ }
+};
diff --git a/packages/components/src/Modal/tests/chromatic/Modal-light.stories.tsx b/packages/components/src/Modal/tests/chromatic/Modal-light.stories.tsx
new file mode 100644
index 00000000..5ff1390a
--- /dev/null
+++ b/packages/components/src/Modal/tests/chromatic/Modal-light.stories.tsx
@@ -0,0 +1,244 @@
+import {
+ Button,
+ ButtonGroup,
+ Card,
+ Content,
+ Div,
+ Flex,
+ Footer,
+ Header,
+ Heading,
+ Image,
+ Modal,
+ Text
+} from "@hopper-ui/components";
+import { hopperParameters } from "@hopper-ui/storybook-addon";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Frog, MossyFrog } from "../assets/index.ts";
+
+const meta = {
+ title: "Components/Modal/light",
+ component: Modal,
+ parameters: {
+ ...hopperParameters({ colorSchemes: ["light"] })
+ },
+ decorators: [
+ Story => (
+
+
+
+ )
+ ],
+ args: {
+ isOpen: true
+ }
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const image = {
+ render: args => (
+
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const choice = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+
+
+
+
+ Frog
+
+ Common frogs are found in ponds, marshes, and forests across the world. Unlike some of their flashier cousins, they rely on stealth and speed rather than bright colors to survive.
+
+
+
+
+
+
+
+
+
+ Mossy Frog
+
+ A mossy tree frog with rough, bark-like skin, blending perfectly into its surroundings for camouflage and protection.
+
+
+
+
+
+
+
+
+ )
+} satisfies Story;
+
+export const header = {
+ render: args => (
+
+ Fascinating Frog Facts!
+ Nature’s Little Acrobats
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ )
+} satisfies Story;
+
+export const footer = {
+ render: args => (
+
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+ )
+} satisfies Story;
+
+export const button = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const buttonGroup = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+ Fascinating Frog Facts!
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const NonDismissable = {
+ ...Default,
+ args: {
+ isDismissible: false
+ }
+} satisfies Story;
+
+export const everything = {
+ render: args => (
+
+ {({ close }) => (
+ <>
+
+ Fascinating Frog Facts!
+ Nature’s Little Acrobats
+
+
+ Frogs are amphibians, meaning they can live both in water and on land! With their powerful legs, some species can jump over 20 times their body length—that’s like a human leaping over a school bus!
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+} satisfies Story;
+
+export const Small = {
+ ...everything,
+ args: {
+ size: "sm"
+ }
+};
+
+export const Large = {
+ ...everything,
+ args: {
+ size: "lg"
+ }
+};
+
+export const ExtraLarge = {
+ ...everything,
+ args: {
+ size: "xl"
+ }
+};
+
+export const Fullscreen = {
+ ...everything,
+ args: {
+ size: "fullscreen"
+ }
+};
+
+export const FullscreenTakeover = {
+ ...everything,
+ args: {
+ size: "fullscreenTakeover"
+ }
+};
diff --git a/packages/components/src/Modal/tests/jest/CustomModal.srr.test.tsx b/packages/components/src/Modal/tests/jest/CustomModal.srr.test.tsx
new file mode 100644
index 00000000..e35f7ffe
--- /dev/null
+++ b/packages/components/src/Modal/tests/jest/CustomModal.srr.test.tsx
@@ -0,0 +1,17 @@
+/**
+ * @jest-environment node
+ */
+import { renderToString } from "react-dom/server";
+
+import { CustomModal } from "../../src/CustomModal.tsx";
+
+describe("CustomModal", () => {
+ it("should render on the server", () => {
+ const renderOnServer = () =>
+ renderToString(
+ Text
+ );
+
+ expect(renderOnServer).not.toThrow();
+ });
+});
diff --git a/packages/components/src/Modal/tests/jest/CustomModal.test.tsx b/packages/components/src/Modal/tests/jest/CustomModal.test.tsx
new file mode 100644
index 00000000..90045350
--- /dev/null
+++ b/packages/components/src/Modal/tests/jest/CustomModal.test.tsx
@@ -0,0 +1,58 @@
+/* eslint-disable testing-library/no-node-access */
+/* Using closest to get the label is the best way, even react-aria does this. */
+import { render, screen } from "@hopper-ui/test-utils";
+import { createRef } from "react";
+
+import { Heading } from "../../../typography/index.ts";
+import { CustomModal, CustomModalContext } from "../../src/index.ts";
+
+describe("CustomModal", () => {
+ it("should render with default class", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveClass("hop-CustomModal");
+ });
+
+ it("should support custom class", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveClass("hop-CustomModal");
+ expect(element).toHaveClass("test");
+ });
+
+ it("should support custom style", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" });
+ });
+
+ it("should support DOM props", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveAttribute("data-foo", "bar");
+ });
+
+ it("should support slots", () => {
+ render(
+
+ Test
+
+ );
+
+ const element = screen.getByRole("dialog");
+
+ expect(element).toHaveAttribute("slot", "test");
+ expect(element).toHaveAttribute("aria-label", "test");
+ });
+
+ it("should support refs", () => {
+ const ref = createRef();
+ render(Test);
+
+ expect(ref.current).not.toBeNull();
+ });
+});
diff --git a/packages/components/src/Modal/tests/jest/Modal.ssr.test.tsx b/packages/components/src/Modal/tests/jest/Modal.ssr.test.tsx
new file mode 100644
index 00000000..61962bfe
--- /dev/null
+++ b/packages/components/src/Modal/tests/jest/Modal.ssr.test.tsx
@@ -0,0 +1,17 @@
+/**
+ * @jest-environment node
+ */
+import { renderToString } from "react-dom/server";
+
+import { Modal } from "../../src/Modal.tsx";
+
+describe("Modal", () => {
+ it("should render on the server", () => {
+ const renderOnServer = () =>
+ renderToString(
+ Text
+ );
+
+ expect(renderOnServer).not.toThrow();
+ });
+});
diff --git a/packages/components/src/Modal/tests/jest/Modal.test.tsx b/packages/components/src/Modal/tests/jest/Modal.test.tsx
new file mode 100644
index 00000000..c7a995cc
--- /dev/null
+++ b/packages/components/src/Modal/tests/jest/Modal.test.tsx
@@ -0,0 +1,58 @@
+/* eslint-disable testing-library/no-node-access */
+/* Using closest to get the label is the best way, even react-aria does this. */
+import { render, screen } from "@hopper-ui/test-utils";
+import { createRef } from "react";
+
+import { Heading } from "../../../typography/index.ts";
+import { Modal, ModalContext } from "../../src/index.ts";
+
+describe("Modal", () => {
+ it("should render with default class", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveClass("hop-Modal");
+ });
+
+ it("should support custom class", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveClass("hop-Modal");
+ expect(element).toHaveClass("test");
+ });
+
+ it("should support custom style", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" });
+ });
+
+ it("should support DOM props", () => {
+ render(Test);
+
+ const element = screen.getByRole("dialog");
+ expect(element).toHaveAttribute("data-foo", "bar");
+ });
+
+ it("should support slots", () => {
+ render(
+
+ Test
+
+ );
+
+ const element = screen.getByRole("dialog");
+
+ expect(element).toHaveAttribute("slot", "test");
+ expect(element).toHaveAttribute("aria-label", "test");
+ });
+
+ it("should support refs", () => {
+ const ref = createRef();
+ render(Test);
+
+ expect(ref.current).not.toBeNull();
+ });
+});
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 0de6fbf9..5f95269d 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -21,6 +21,7 @@ export * from "./layout/index.ts";
export * from "./Link/index.ts";
export * from "./ListBox/index.ts";
export * from "./ListBoxSection/index.ts";
+export * from "./Modal/index.ts";
export * from "./overlays/Popover/index.ts";
export * from "./radio/index.ts";
export * from "./SegmentedControl/index.ts";