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 + + + 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 + + + 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! + + Frog + + )} + + + ); +} 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! + +
+ Copyright 2025 +
+
+
+ ); +} 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 ( + + + + Frog + 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 ( + + + {renderProps => ( + + {children(renderProps)} + + )} + + + ); +}; + +/** + * 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 ( + + + {renderProps => ( + + {isDismissible && } + + {children(renderProps)} + + + )} + + + ); +}; + +/** + * 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 => ( + + Frog + 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 + + + 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 + + + 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! + + +
+ Copyright 2025 +
+
+ ) +} 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 }) => ( + <> + Frog + 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! + + +
+ Copyright 2021 +
+ + + + + + )} +
+ ) +} 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 => ( + + Frog + 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 + + + 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 + + + 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! + + +
+ Copyright 2025 +
+
+ ) +} 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 }) => ( + <> + Frog + 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! + + +
+ Copyright 2021 +
+ + + + + + )} +
+ ) +} 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";