diff --git a/apps/docs/content/components/content/IllustratedMessage.mdx b/apps/docs/content/components/content/IllustratedMessage.mdx new file mode 100644 index 000000000..bc274a678 --- /dev/null +++ b/apps/docs/content/components/content/IllustratedMessage.mdx @@ -0,0 +1,63 @@ +--- +title: IllustratedMessage +description: An illustrated message display an image and a message, usually for an empty state or an error page. +category: "content" +links: + source: https://github.com/gsoft-inc/wl-hopper/blob/main/packages/components/src/IllustratedMessage/src/IllustratedMessage.tsx +--- + + + +## Anatomy + +### Composed Components + +A `IllustratedMessage` uses the following components. + + + +## Usage + +### Sizes + +An illustrated message can use different sizes. + + + +### Image + +An illustrated message can handle images (jpg, png). + + + +### Buttons + +An illustrated message can handle either a button + + + +or a group of buttons + + + +### SVG + +An illustrated message can handle svgs. + + + +## Props + + + +## Migration Notes + + + +### Layout Samples + +To facilitate the migration process, we've provided layout samples as reference guides. These examples demonstrate how to recreate features previously supported in [Orbiter](https://wl-orbiter-website.netlify.app/?path=/docs/illustrated-message--docs). + +#### Horizontal + + diff --git a/apps/docs/content/components/content/Image.mdx b/apps/docs/content/components/content/Image.mdx new file mode 100644 index 000000000..ff4b97870 --- /dev/null +++ b/apps/docs/content/components/content/Image.mdx @@ -0,0 +1,67 @@ +--- +title: Image +description: An image component that can be used to display images. +category: "content" +links: + source: https://github.com/gsoft-inc/wl-hopper/blob/main/packages/components/src/Image/src/Image.tsx +--- + + + +## Usage + +### Shapes + +An image can use a different shape. + + + +### Sizes + +An image can vary in size. + + + +## Object fit + +An image can have different object fits + + + +### Retina + +You can let the browser decide which image is best to serve according to the user device screen pixel density. + +It is highly recommended to serve a 1x image as well as a 2x image, twice the intended size. This assures the user has the best looking image possible. + +Avoid serving images that are unecessary big, images should be resized to the intended final image display size. This assures we don't waste bandwith for the user. + + + +## SvgImage + +For some use cases, like dark mode support, an SVG image is a better fit than a standard PNG or JPG image. + + + +### Size + +An SVG image size can be specified with the `width` and `height` props. + + + +### Color + +An SVG image `stroke` and `fill` color can vary. + + + +## Props + +### Image + + + +### SvgImage + + diff --git a/apps/docs/examples/Preview.ts b/apps/docs/examples/Preview.ts index 8b53f2d40..385cd6b61 100644 --- a/apps/docs/examples/Preview.ts +++ b/apps/docs/examples/Preview.ts @@ -353,6 +353,51 @@ export const Previews: Record = { "typography/Heading/docs/advancedCustomization": { component: lazy(() => import("@/../../packages/components/src/typography/Heading/docs/advancedCustomization.tsx")) }, + "IllustratedMessage/docs/default": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/default.tsx")) + }, + "IllustratedMessage/docs/size": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/size.tsx")) + }, + "IllustratedMessage/docs/image": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/image.tsx")) + }, + "IllustratedMessage/docs/button": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/button.tsx")) + }, + "IllustratedMessage/docs/buttonGroup": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/buttonGroup.tsx")) + }, + "IllustratedMessage/docs/svg": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/svg.tsx")) + }, + "IllustratedMessage/docs/migration/horizontal": { + component: lazy(() => import("@/../../packages/components/src/IllustratedMessage/docs/migration/horizontal.tsx")) + }, + "Image/docs/image": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/image.tsx")) + }, + "Image/docs/shape": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/shape.tsx")) + }, + "Image/docs/size": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/size.tsx")) + }, + "Image/docs/objectFit": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/objectFit.tsx")) + }, + "Image/docs/retina": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/retina.tsx")) + }, + "Image/docs/svg": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/svg.tsx")) + }, + "Image/docs/svgSize": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/svgSize.tsx")) + }, + "Image/docs/svgColor": { + component: lazy(() => import("@/../../packages/components/src/Image/docs/svgColor.tsx")) + }, "typography/Label/docs/preview": { component: lazy(() => import("@/../../packages/components/src/typography/Label/docs/preview.tsx")) }, diff --git a/apps/docs/public/frog2x.jpg b/apps/docs/public/frog2x.jpg new file mode 100644 index 000000000..c94f5fa32 Binary files /dev/null and b/apps/docs/public/frog2x.jpg differ diff --git a/packages/components/src/IllustratedMessage/assets/NoResults.tsx b/packages/components/src/IllustratedMessage/assets/NoResults.tsx new file mode 100644 index 000000000..d55a6db89 --- /dev/null +++ b/packages/components/src/IllustratedMessage/assets/NoResults.tsx @@ -0,0 +1,6 @@ +/* eslint-disable */ +import { Ref, SVGProps, forwardRef } from "react"; +const InnerNoResultsIcon = (props: SVGProps, ref: Ref) => ; +const ForwardRef = forwardRef(InnerNoResultsIcon); +export { ForwardRef as ReactComponent }; +/* eslint-enable */ diff --git a/packages/components/src/IllustratedMessage/assets/frog.jpg b/packages/components/src/IllustratedMessage/assets/frog.jpg new file mode 100644 index 000000000..333b615fa Binary files /dev/null and b/packages/components/src/IllustratedMessage/assets/frog.jpg differ diff --git a/packages/components/src/IllustratedMessage/assets/index.ts b/packages/components/src/IllustratedMessage/assets/index.ts new file mode 100644 index 000000000..0e76666bc --- /dev/null +++ b/packages/components/src/IllustratedMessage/assets/index.ts @@ -0,0 +1,4 @@ +import Frog from "./frog.jpg"; +import { ReactComponent as NoResults } from "./NoResults.tsx"; + +export { Frog, NoResults }; diff --git a/packages/components/src/IllustratedMessage/docs/button.tsx b/packages/components/src/IllustratedMessage/docs/button.tsx new file mode 100644 index 000000000..4fcd043cc --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/button.tsx @@ -0,0 +1,14 @@ +import { Button, Content, Heading, IllustratedMessage, SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + + No results found + Try searching for something else. + + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/buttonGroup.tsx b/packages/components/src/IllustratedMessage/docs/buttonGroup.tsx new file mode 100644 index 000000000..f7a20f613 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/buttonGroup.tsx @@ -0,0 +1,17 @@ +import { Button, ButtonGroup, Content, Heading, IllustratedMessage, SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + + No results found + Try searching for something else. + + + + + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/default.tsx b/packages/components/src/IllustratedMessage/docs/default.tsx new file mode 100644 index 000000000..9e6e64454 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/default.tsx @@ -0,0 +1,13 @@ +import { Content, Heading, IllustratedMessage, SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + + No results found + Try searching for something else. + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/image.tsx b/packages/components/src/IllustratedMessage/docs/image.tsx new file mode 100644 index 000000000..004be4f12 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/image.tsx @@ -0,0 +1,11 @@ +import { Content, Heading, IllustratedMessage, Image } from "@hopper-ui/components"; + +export default function Example() { + return ( + + No Results + No results found + It seems like there’s nothing here for now. Hop on and add something new! + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/migration-notes.md b/packages/components/src/IllustratedMessage/docs/migration-notes.md new file mode 100644 index 000000000..180554b63 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/migration-notes.md @@ -0,0 +1,4 @@ +Coming from Orbiter, you should be aware of the following changes: + +- `orientation` has been removed. Refer to this [sample](#horizontal) to see an implementation example for a horizontal orientation. +- `width` and `height` prop will now affect the whole wrapper instead of just the image. diff --git a/packages/components/src/IllustratedMessage/docs/migration/horizontal.tsx b/packages/components/src/IllustratedMessage/docs/migration/horizontal.tsx new file mode 100644 index 000000000..48a64d32f --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/migration/horizontal.tsx @@ -0,0 +1,15 @@ +import { Content, Heading, Inline, Stack, SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../../assets/index.ts"; + +export default function Example() { + return ( + + + + No results found + Please try another search term. + + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/size.tsx b/packages/components/src/IllustratedMessage/docs/size.tsx new file mode 100644 index 000000000..99b007893 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/size.tsx @@ -0,0 +1,24 @@ +import { Content, Heading, IllustratedMessage, Image, Stack } from "@hopper-ui/components"; + +export default function Example() { + return ( + + + Frog + No results found + It seems like there’s nothing here for now. Hop on and add something new. + + + Frog + No results found + It seems like there’s nothing here for now. Hop on and add something new. + + + Frog + No results found + It seems like there’s nothing here for now. Hop on and add something new. + + + + ); +} diff --git a/packages/components/src/IllustratedMessage/docs/svg.tsx b/packages/components/src/IllustratedMessage/docs/svg.tsx new file mode 100644 index 000000000..9e6e64454 --- /dev/null +++ b/packages/components/src/IllustratedMessage/docs/svg.tsx @@ -0,0 +1,13 @@ +import { Content, Heading, IllustratedMessage, SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + + No results found + Try searching for something else. + + ); +} diff --git a/packages/components/src/IllustratedMessage/index.ts b/packages/components/src/IllustratedMessage/index.ts new file mode 100644 index 000000000..401c73ac2 --- /dev/null +++ b/packages/components/src/IllustratedMessage/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/components/src/IllustratedMessage/src/IllustratedMessage.module.css b/packages/components/src/IllustratedMessage/src/IllustratedMessage.module.css new file mode 100644 index 000000000..47a822c4a --- /dev/null +++ b/packages/components/src/IllustratedMessage/src/IllustratedMessage.module.css @@ -0,0 +1,105 @@ +.hop-IllustratedMessage { + --hop-IllustratedMessage-content-font-size: var(--hop-body-md-font-size); + --hop-IllustratedMessage-content-font-family: var(--hop-body-md-font-family); + --hop-IllustratedMessage-content-font-weight: var(--hop-body-md-font-weight); + --hop-IllustratedMessage-content-line-height: var(--hop-body-md-line-height); + --hop-IllustratedMessage-heading-margin-top: var(--hop-space-stack-md); + --hop-IllustratedMessage-content-margin-top: var(--hop-space-stack-sm); + --hop-IllustratedMessage-buttons-margin-top: var(--hop-space-stack-md); + + /* Small */ + --hop-IllustratedMessage-sm-max-width: 20rem; + --hop-IllustratedMessage-sm-heading-font-size: var(--hop-body-md-font-size); + --hop-IllustratedMessage-sm-heading-font-family: var(--hop-body-md-font-family); + --hop-IllustratedMessage-sm-heading-font-weight: var(--hop-body-md-semibold-font-weight); + --hop-IllustratedMessage-sm-heading-line-height: var(--hop-body-md-line-height); + + /* Medium */ + --hop-IllustratedMessage-md-max-width: 25rem; + --hop-IllustratedMessage-md-heading-font-size: var(--hop-body-lg-font-size); + --hop-IllustratedMessage-md-heading-font-family: var(--hop-body-lg-font-family); + --hop-IllustratedMessage-md-heading-font-weight: var(--hop-body-lg-semibold-font-weight); + --hop-IllustratedMessage-md-heading-line-height: var(--hop-body-lg-line-height); + + /* Large */ + --hop-IllustratedMessage-lg-max-width: 28.5rem; + --hop-IllustratedMessage-lg-heading-font-size: var(--hop-body-lg-font-size); + --hop-IllustratedMessage-lg-heading-font-family: var(--hop-body-lg-font-family); + --hop-IllustratedMessage-lg-heading-font-weight: var(--hop-body-lg-semibold-font-weight); + --hop-IllustratedMessage-lg-heading-line-height: var(--hop-body-lg-line-height); + + display: grid; + grid-template-areas: + "image" + "heading" + "content" + "buttons"; + place-items: center; + + max-inline-size: var(--max-width); + + text-align: center; +} + +.hop-IllustratedMessage--sm { + --max-width: var(--hop-IllustratedMessage-sm-max-width); +} + +.hop-IllustratedMessage--md { + --max-width: var(--hop-IllustratedMessage-md-max-width); +} + +.hop-IllustratedMessage--lg { + --max-width: var(--hop-IllustratedMessage-lg-max-width); +} + +.hop-IllustratedMessage__image { + grid-area: image; +} + +.hop-IllustratedMessage__heading { + grid-area: heading; + margin-block-start: var(--hop-IllustratedMessage-heading-margin-top); +} + +.hop-IllustratedMessage--sm, .hop-IllustratedMessage__heading { + font-family: var(--hop-IllustratedMessage-sm-heading-font-family); + font-size: var(--hop-IllustratedMessage-sm-heading-font-size); + font-weight: var(--hop-IllustratedMessage-sm-heading-font-weight); + line-height: var(--hop-IllustratedMessage-sm-heading-line-height); +} + +.hop-IllustratedMessage-md, .hop-IllustratedMessage__heading { + font-family: var(--hop-IllustratedMessage-md-heading-font-family); + font-size: var(--hop-IllustratedMessage-md-heading-font-size); + font-weight: var(--hop-IllustratedMessage-md-heading-font-weight); + line-height: var(--hop-IllustratedMessage-md-heading-line-height); +} + +.hop-IllustratedMessage--lg, .hop-IllustratedMessage__heading { + font-family: var(--hop-IllustratedMessage-lg-heading-font-family); + font-size: var(--hop-IllustratedMessage-lg-heading-font-size); + font-weight: var(--hop-IllustratedMessage-lg-heading-font-weight); + line-height: var(--hop-IllustratedMessage-lg-heading-line-height); +} + +.hop-IllustratedMessage__content { + grid-area: content; + + margin-block-start: var(--hop-IllustratedMessage-content-margin-top); + + font-family: var(--hop-IllustratedMessage-content-font-family); + font-size: var(--hop-IllustratedMessage-content-font-size); + font-weight: var(--hop-IllustratedMessage-content-font-weight); + line-height: var(--hop-IllustratedMessage-content-line-height); +} + +.hop-IllustratedMessage__buttonGroup { + grid-area: buttons; + margin-block-start: var(--hop-IllustratedMessage-buttons-margin-top); +} + +.hop-IllustratedMessage__button { + grid-area: buttons; + margin-block-start: var(--hop-IllustratedMessage-buttons-margin-top); +} diff --git a/packages/components/src/IllustratedMessage/src/IllustratedMessage.tsx b/packages/components/src/IllustratedMessage/src/IllustratedMessage.tsx new file mode 100644 index 000000000..b328f5f64 --- /dev/null +++ b/packages/components/src/IllustratedMessage/src/IllustratedMessage.tsx @@ -0,0 +1,102 @@ +import { Div, useStyledSystem, type StyledSystemProps } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { forwardRef, type CSSProperties, type ForwardedRef } from "react"; +import { useContextProps } from "react-aria-components"; + +import { ButtonContext, ButtonGroupContext } from "../../buttons/index.ts"; +import { ImageContext, SvgImageContext } from "../../Image/index.ts"; +import { ContentContext } from "../../layout/index.ts"; +import { HeadingContext } from "../../typography/Heading/index.ts"; +import { cssModule, SlotProvider, type AccessibleSlotProps, type BaseComponentDOMProps } from "../../utils/index.ts"; + +import { IllustratedMessageContext } from "./IllustratedMessageContext.ts"; + +import styles from "./IllustratedMessage.module.css"; + +export const GlobalIllustratedMessageCssSelector = "hop-IllustratedMessage"; + +export type IllustratedMessageSize = "sm" | "md" | "lg"; + +export interface IllustratedMessageProps extends StyledSystemProps, AccessibleSlotProps, BaseComponentDOMProps { + /** + * The size of the IllustratedMessage. + * @default "md" + */ + size?: IllustratedMessageSize; +} + +function IllustratedMessage(props: IllustratedMessageProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, IllustratedMessageContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + style, + slot, + children, + size = "md", + ...otherProps + } = ownProps; + + const classNames = clsx( + className, + GlobalIllustratedMessageCssSelector, + cssModule( + styles, + "hop-IllustratedMessage", + size + ), + stylingProps.className + ); + + const mergedStyles: CSSProperties = { + ...stylingProps.style, + ...style + }; + + return ( +
+ + {children} + +
+ ); +} + +/** + * An illustrated message display an image and a message, usually for an empty state or an error page. + * + * [View Documentation](TODO) + */ +const _IllustratedMessage = forwardRef(IllustratedMessage); +_IllustratedMessage.displayName = "IllustratedMessage"; + +export { _IllustratedMessage as IllustratedMessage }; diff --git a/packages/components/src/IllustratedMessage/src/IllustratedMessageContext.ts b/packages/components/src/IllustratedMessage/src/IllustratedMessageContext.ts new file mode 100644 index 000000000..3e88a41dc --- /dev/null +++ b/packages/components/src/IllustratedMessage/src/IllustratedMessageContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { IllustratedMessageProps } from "./IllustratedMessage.tsx"; + +export const IllustratedMessageContext = createContext>({}); + +IllustratedMessageContext.displayName = "IllustratedMessageContext"; diff --git a/packages/components/src/IllustratedMessage/src/index.ts b/packages/components/src/IllustratedMessage/src/index.ts new file mode 100644 index 000000000..d2705c836 --- /dev/null +++ b/packages/components/src/IllustratedMessage/src/index.ts @@ -0,0 +1,3 @@ +export * from "./IllustratedMessage.tsx"; +export * from "./IllustratedMessageContext.ts"; + diff --git a/packages/components/src/IllustratedMessage/tests/chromatic/IllustratedMessage.stories.tsx b/packages/components/src/IllustratedMessage/tests/chromatic/IllustratedMessage.stories.tsx new file mode 100644 index 000000000..36a159e13 --- /dev/null +++ b/packages/components/src/IllustratedMessage/tests/chromatic/IllustratedMessage.stories.tsx @@ -0,0 +1,184 @@ + +import { Button, ButtonGroup, Content, Div, Heading, Image, Stack, SvgImage } from "@hopper-ui/components"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Frog, NoResults } from "../../assets/index.ts"; +import { IllustratedMessage } from "../../index.ts"; + +const meta = { + title: "Components/IllustratedMessage", + component: IllustratedMessage, + decorators: [ + Story => ( +
+ +
+ ) + ] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default = { + render: () => ( + + Frog + No results found + Try searching for something else. + + ) +} satisfies Story; + +export const Sizes = { + render: () => ( + + + Frog + No results found + Try searching for something else. + + + Frog + No results found + Try searching for something else. + + + Frog + No results found + Try searching for something else. + + + ) +} satisfies Story; + +export const SVG = { + render: () => ( + + + No results found + Try searching for something else. + + ) +}; + +export const VeryLongTitle = { + render: () => ( + + Frog + No results found or "Mars" or another continent. + Try searching for something else. + + ) +} satisfies Story; + +export const VeryLongText = { + render: () => ( + + Frog + No results found + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc suscipit metus neque, non pharetra enim tincidunt dictum. + Fusce in ultricies turpis, vitae finibus nunc. Quisque laoreet sit amet eros eget volutpat. Pellentesque non nulla dui. Sed nec felis quam. Vestibulum velit magna, fringilla ut neque cursus, porta rhoncus nulla. Suspendisse auctor sollicitudin tortor, quis viverra tellus egestas sed. + Pellentesque ut dignissim nisi. Duis sit amet ex bibendum, pharetra purus eget, varius massa. In pulvinar dui quis dignissim commodo. Nulla facilisi.. + + + ) +} satisfies Story; + +export const NoHeading = { + render: () => ( + + Frog + Try searching for something else. + + ) +} satisfies Story; + +export const NoDescription = { + render: () => ( + + Frog + No results found + + ) +} satisfies Story; + +export const ImageTooWide = { + render: () => ( + + Frog + No results found + Try searching for something else. + + ) +} satisfies Story; + +export const Zoom = { + render: () => ( + +
+ + Frog + No results found + Try searching for something else. + +
+
+ + Frog + No results found + Try searching for something else. + +
+
+ ) +} satisfies Story; + +export const WithButton = { + render: () => ( + + Frog + No results found + Try searching for something else. + + + ) +} satisfies Story; + +export const WithButtonGroup = { + render: () => ( + + Frog + No results found + Try searching for something else. + + + + + + ) +} satisfies Story; + +export const Styling = { + render: () => ( + + + Frog + No results found + Try searching for something else. + + + Frog + No results found + Try searching for something else. + + + Frog + No results found + Try searching for something else. + + + ) +} satisfies Story; diff --git a/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.ssr.test.tsx b/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.ssr.test.tsx new file mode 100644 index 000000000..e89e3d4a0 --- /dev/null +++ b/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.ssr.test.tsx @@ -0,0 +1,17 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { IllustratedMessage } from "../../src/IllustratedMessage.tsx"; + +describe("IllustratedMessage", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + Text + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.test.tsx b/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.test.tsx new file mode 100644 index 000000000..982896a8f --- /dev/null +++ b/packages/components/src/IllustratedMessage/tests/jest/IllustratedMessage.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { IllustratedMessage } from "../../src/IllustratedMessage.tsx"; +import { IllustratedMessageContext } from "../../src/IllustratedMessageContext.ts"; + +describe("IllustratedMessage", () => { + it("should render with default class", () => { + render(Test); + + const element = screen.getByText("Test"); + expect(element).toHaveClass("hop-IllustratedMessage"); + }); + + it("should support custom class", () => { + render(Test); + + const element = screen.getByText("Test"); + expect(element).toHaveClass("hop-IllustratedMessage"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(Test); + + const element = screen.getByText("Test"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(Test); + + const element = screen.getByText("Test"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + Test + + ); + + const element = screen.getByText("Test"); + 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(); + expect(ref.current instanceof HTMLDivElement).toBeTruthy(); + }); +}); diff --git a/packages/components/src/Image/assets/NoResults.tsx b/packages/components/src/Image/assets/NoResults.tsx new file mode 100644 index 000000000..d55a6db89 --- /dev/null +++ b/packages/components/src/Image/assets/NoResults.tsx @@ -0,0 +1,6 @@ +/* eslint-disable */ +import { Ref, SVGProps, forwardRef } from "react"; +const InnerNoResultsIcon = (props: SVGProps, ref: Ref) => ; +const ForwardRef = forwardRef(InnerNoResultsIcon); +export { ForwardRef as ReactComponent }; +/* eslint-enable */ diff --git a/packages/components/src/Image/assets/frog.jpg b/packages/components/src/Image/assets/frog.jpg new file mode 100644 index 000000000..333b615fa Binary files /dev/null and b/packages/components/src/Image/assets/frog.jpg differ diff --git a/packages/components/src/Image/assets/index.ts b/packages/components/src/Image/assets/index.ts new file mode 100644 index 000000000..0e76666bc --- /dev/null +++ b/packages/components/src/Image/assets/index.ts @@ -0,0 +1,4 @@ +import Frog from "./frog.jpg"; +import { ReactComponent as NoResults } from "./NoResults.tsx"; + +export { Frog, NoResults }; diff --git a/packages/components/src/Image/docs/image.tsx b/packages/components/src/Image/docs/image.tsx new file mode 100644 index 000000000..e0af99af8 --- /dev/null +++ b/packages/components/src/Image/docs/image.tsx @@ -0,0 +1,7 @@ +import { Image } from "@hopper-ui/components"; + +export default function Example() { + return ( + Frog + ); +} diff --git a/packages/components/src/Image/docs/objectFit.tsx b/packages/components/src/Image/docs/objectFit.tsx new file mode 100644 index 000000000..66d877595 --- /dev/null +++ b/packages/components/src/Image/docs/objectFit.tsx @@ -0,0 +1,9 @@ +import { Div, Image } from "@hopper-ui/components"; + +export default function Example() { + return ( +
+ Frog +
+ ); +} diff --git a/packages/components/src/Image/docs/retina.tsx b/packages/components/src/Image/docs/retina.tsx new file mode 100644 index 000000000..6f2a707b1 --- /dev/null +++ b/packages/components/src/Image/docs/retina.tsx @@ -0,0 +1,7 @@ +import { Image } from "@hopper-ui/components"; + +export default function Example() { + return ( + Frog + ); +} diff --git a/packages/components/src/Image/docs/shape.tsx b/packages/components/src/Image/docs/shape.tsx new file mode 100644 index 000000000..d8922ca04 --- /dev/null +++ b/packages/components/src/Image/docs/shape.tsx @@ -0,0 +1,10 @@ +import { Image, Inline } from "@hopper-ui/components"; + +export default function Example() { + return ( + + Frog + Frog + + ); +} diff --git a/packages/components/src/Image/docs/size.tsx b/packages/components/src/Image/docs/size.tsx new file mode 100644 index 000000000..f4a3358ce --- /dev/null +++ b/packages/components/src/Image/docs/size.tsx @@ -0,0 +1,11 @@ +import { Image, Inline } from "@hopper-ui/components"; + +export default function Example() { + return ( + + Frog + Frog + Frog + + ); +} diff --git a/packages/components/src/Image/docs/svg.tsx b/packages/components/src/Image/docs/svg.tsx new file mode 100644 index 000000000..0d74b03b7 --- /dev/null +++ b/packages/components/src/Image/docs/svg.tsx @@ -0,0 +1,9 @@ +import { SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + ); +} diff --git a/packages/components/src/Image/docs/svgColor.tsx b/packages/components/src/Image/docs/svgColor.tsx new file mode 100644 index 000000000..b76cbd8c3 --- /dev/null +++ b/packages/components/src/Image/docs/svgColor.tsx @@ -0,0 +1,14 @@ +import { SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + ); +} diff --git a/packages/components/src/Image/docs/svgSize.tsx b/packages/components/src/Image/docs/svgSize.tsx new file mode 100644 index 000000000..fe3045f0f --- /dev/null +++ b/packages/components/src/Image/docs/svgSize.tsx @@ -0,0 +1,9 @@ +import { SvgImage } from "@hopper-ui/components"; + +import { NoResults } from "../assets/index.ts"; + +export default function Example() { + return ( + + ); +} diff --git a/packages/components/src/Image/index.ts b/packages/components/src/Image/index.ts new file mode 100644 index 000000000..401c73ac2 --- /dev/null +++ b/packages/components/src/Image/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/components/src/Image/src/Image.module.css b/packages/components/src/Image/src/Image.module.css new file mode 100644 index 000000000..caf532cb0 --- /dev/null +++ b/packages/components/src/Image/src/Image.module.css @@ -0,0 +1,16 @@ +.hop-Image { + display: block; + max-inline-size: 100%; + block-size: auto; + vertical-align: middle; +} + +/* SHAPE | ROUNDED */ +.hop-Image--rounded { + border-radius: var(--hop-shape-rounded-md); +} + +/* SHAPE | CIRCULAR */ +.hop-Image--circular { + border-radius: var(--hop-shape-circle); +} diff --git a/packages/components/src/Image/src/Image.tsx b/packages/components/src/Image/src/Image.tsx new file mode 100644 index 000000000..c5ae30d10 --- /dev/null +++ b/packages/components/src/Image/src/Image.tsx @@ -0,0 +1,90 @@ +import { type ResponsiveProp, type StyledSystemProps, useResponsiveValue, useStyledSystem } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { type CSSProperties, type ForwardedRef, forwardRef, type HTMLProps } from "react"; +import { useContextProps } from "react-aria-components"; + +import { type AccessibleSlotProps, type BaseComponentDOMProps, cssModule } from "../../utils/index.ts"; + +import { ImageContext } from "./ImageContext.ts"; + +import styles from "./Image.module.css"; + +export const GlobalImageCssSelector = "hop-Image"; + +export interface ImageProps extends + StyledSystemProps, + AccessibleSlotProps, + Omit, + Omit, "slot" | "color" | "content" | "height" | "width" | "src"> { + /** + * The image shape. + */ + shape?: "straight" | "rounded" | "circular"; + /** + * An image path. + */ + src?: ResponsiveProp; +} + +function Image(props: ImageProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, ImageContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + style, + shape, + src, + slot, + alt, + ...otherProps + } = ownProps; + + if (!alt) { + console.warn( + "The `alt` prop was not provided to an image. " + + "Add `alt` text for screen readers, or set `alt=\"\"` prop to indicate that the image " + + "is decorative or redundant with displayed text and should not be announced by screen readers." + ); + } + + const classNames = clsx( + className, + GlobalImageCssSelector, + cssModule( + styles, + "hop-Image", + shape + ), + stylingProps.className + ); + + const mergedStyles: CSSProperties = { + ...stylingProps.style, + ...style + }; + + const srcValue = useResponsiveValue(src); + + return ( + {alt + ); +} + +/** + * An image component that can be used to display images. + * + * [View Documentation](TODO) + */ +const _Image = forwardRef(Image); +_Image.displayName = "Image"; + +export { _Image as Image }; diff --git a/packages/components/src/Image/src/ImageContext.ts b/packages/components/src/Image/src/ImageContext.ts new file mode 100644 index 000000000..d2c92d481 --- /dev/null +++ b/packages/components/src/Image/src/ImageContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { ImageProps } from "./Image.tsx"; + +export const ImageContext = createContext>({}); + +ImageContext.displayName = "ImageContext"; diff --git a/packages/components/src/Image/src/SvgImage.module.css b/packages/components/src/Image/src/SvgImage.module.css new file mode 100644 index 000000000..edf2338c6 --- /dev/null +++ b/packages/components/src/Image/src/SvgImage.module.css @@ -0,0 +1,6 @@ +.hop-SvgImage { + display: block; + max-inline-size: 100%; + block-size: auto; + vertical-align: middle; +} diff --git a/packages/components/src/Image/src/SvgImage.tsx b/packages/components/src/Image/src/SvgImage.tsx new file mode 100644 index 000000000..cc354a228 --- /dev/null +++ b/packages/components/src/Image/src/SvgImage.tsx @@ -0,0 +1,76 @@ +import { type ResponsiveProp, type StyledSystemProps, useResponsiveValue, useStyledSystem } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { type CSSProperties, type ElementType, type ForwardedRef, forwardRef, type HTMLProps } from "react"; +import { useContextProps } from "react-aria-components"; + +import { type AccessibleSlotProps, type BaseComponentDOMProps, cssModule } from "../../utils/index.ts"; + +import { SvgImageContext } from "./SvgImageContext.ts"; + +import styles from "./SvgImage.module.css"; + +export const GlobalSvgImageCssSelector = "hop-SvgImage"; + +export interface SvgImageProps extends + StyledSystemProps, + AccessibleSlotProps, + Omit, + Omit, "color" | "slot" | "content" | "height" | "width" | "src"> { + /** + * An SVG as a component. + */ + src?: ResponsiveProp; +} + +function SvgImage(props: SvgImageProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, SvgImageContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + style, + src, + ...otherProps + } = ownProps; + + const classNames = clsx( + className, + GlobalSvgImageCssSelector, + cssModule( + styles, + "hop-SvgImage" + ), + stylingProps.className + ); + + const mergedStyles: CSSProperties = { + ...stylingProps.style, + ...style + }; + + const SvgComponent = useResponsiveValue(src); + + if (!SvgComponent) { + console.error("SvgImage component expects src"); + + return null; + } + + return ; +} + +/** + * An SvgImage component that can be used to display SVGs. + * + * [View Documentation](TODO) + */ +const _SvgImage = forwardRef(SvgImage); +_SvgImage.displayName = "SvgImage"; + +export { _SvgImage as SvgImage }; diff --git a/packages/components/src/Image/src/SvgImageContext.ts b/packages/components/src/Image/src/SvgImageContext.ts new file mode 100644 index 000000000..a0f62766e --- /dev/null +++ b/packages/components/src/Image/src/SvgImageContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { SvgImageProps } from "./SvgImage.tsx"; + +export const SvgImageContext = createContext>({}); + +SvgImageContext.displayName = "SvgImageContext"; diff --git a/packages/components/src/Image/src/index.ts b/packages/components/src/Image/src/index.ts new file mode 100644 index 000000000..c3a415c17 --- /dev/null +++ b/packages/components/src/Image/src/index.ts @@ -0,0 +1,5 @@ +export * from "./Image.tsx"; +export * from "./ImageContext.ts"; +export * from "./SvgImage.tsx"; +export * from "./SvgImageContext.ts"; + diff --git a/packages/components/src/Image/tests/chromatic/Image.stories.tsx b/packages/components/src/Image/tests/chromatic/Image.stories.tsx new file mode 100644 index 000000000..dcd1ce332 --- /dev/null +++ b/packages/components/src/Image/tests/chromatic/Image.stories.tsx @@ -0,0 +1,152 @@ +import { Div } from "@hopper-ui/styled-system"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Inline } from "../../../layout/index.ts"; +import { Frog } from "../../assets/index.ts"; +import { Image } from "../../src/Image.tsx"; + +const meta = { + title: "Components/Image", + component: Image, + args: { + src: Frog, + alt: "Frog" + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default = {} satisfies Story; + +export const Contained = { + decorators: [Story => ( +
+ +
+ )] +} satisfies Story; + +export const Size = { + render: args => ( + + + + + ) +} satisfies Story; + +export const Straight = { + render: args => ( + + + + + + + + ), + args: { + shape: "straight" + } +} satisfies Story; + +export const Rounded = { + render: args => ( + + + + + + + + ), + args: { + shape: "rounded" + } +} satisfies Story; + +export const Circular = { + render: args => ( + + + + + + + + ), + args: { + shape: "circular" + } +} satisfies Story; + +export const ObjectFit = { + render: args => ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ), + args: { + UNSAFE_width: "100%", + UNSAFE_height: "100%" + } +} satisfies Story; + +export const ObjectPosition = { + render: args => ( + + + + + + + ), + args: { + objectFit: "none", + UNSAFE_width: "200px", + UNSAFE_height: "200px" + } +} satisfies Story; + +export const ZoomIn = { + decorators: [Story => ( +
+ +
+ )] +} satisfies Story; + +export const ZoomOut = { + decorators: [Story => ( +
+ +
+ )] +} satisfies Story; + +export const Styling = { + render: args => ( + + + + + + ) +} satisfies Story; + diff --git a/packages/components/src/Image/tests/chromatic/SvgImage.stories.tsx b/packages/components/src/Image/tests/chromatic/SvgImage.stories.tsx new file mode 100644 index 000000000..89ae34181 --- /dev/null +++ b/packages/components/src/Image/tests/chromatic/SvgImage.stories.tsx @@ -0,0 +1,53 @@ + +import type { Meta, StoryObj } from "@storybook/react"; + +import { Inline } from "../../../layout/index.ts"; +import { NoResults } from "../../assets/index.ts"; +import { SvgImage } from "../../src/SvgImage.tsx"; + +const meta = { + title: "Components/SvgImage", + component: SvgImage, + args: { + src: NoResults, + "aria-label": "No Results", + stroke: "neutral" + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Stroke = { + args: { + stroke: "neutral" + } +} satisfies Story; + +export const Fill = { + args: { + fill: "neutral" + } +} satisfies Story; + +export const Width = { + args: { + UNSAFE_width: "100px" + } +} satisfies Story; + +export const Height = { + args: { + UNSAFE_height: "100px" + } +} satisfies Story; + +export const Styling = { + render: args => ( + + + + + ) +} satisfies Story; diff --git a/packages/components/src/Image/tests/jest/Image.ssr.test.tsx b/packages/components/src/Image/tests/jest/Image.ssr.test.tsx new file mode 100644 index 000000000..e35960c17 --- /dev/null +++ b/packages/components/src/Image/tests/jest/Image.ssr.test.tsx @@ -0,0 +1,17 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { Image } from "../../src/Image.tsx"; + +describe("Image", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + test + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/Image/tests/jest/Image.test.tsx b/packages/components/src/Image/tests/jest/Image.test.tsx new file mode 100644 index 000000000..5754c4a0d --- /dev/null +++ b/packages/components/src/Image/tests/jest/Image.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { Image } from "../../src/Image.tsx"; +import { ImageContext } from "../../src/ImageContext.ts"; + +describe("Image", () => { + it("should render with default class", () => { + render(test); + + const element = screen.getByRole("img"); + expect(element).toHaveClass("hop-Image"); + }); + + it("should support custom class", () => { + render(test); + + const element = screen.getByRole("img"); + expect(element).toHaveClass("hop-Image"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(test); + + const element = screen.getByRole("img"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(test); + + const element = screen.getByRole("img"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + test + + ); + + const element = screen.getByRole("img"); + 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(); + expect(ref.current instanceof HTMLImageElement).toBeTruthy(); + }); +}); diff --git a/packages/components/src/Image/tests/jest/SvgImage.ssr.test.tsx b/packages/components/src/Image/tests/jest/SvgImage.ssr.test.tsx new file mode 100644 index 000000000..81947c46c --- /dev/null +++ b/packages/components/src/Image/tests/jest/SvgImage.ssr.test.tsx @@ -0,0 +1,35 @@ +/** + * @jest-environment node + */ +import { forwardRef, type ComponentProps } from "react"; +import { renderToString } from "react-dom/server"; + +import { SvgImage } from "../../src/SvgImage.tsx"; + +const BasicSvg = forwardRef>((props, ref) => { + return ( + + + + + + ); +}); + +describe("SvgImage", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/Image/tests/jest/SvgImage.test.tsx b/packages/components/src/Image/tests/jest/SvgImage.test.tsx new file mode 100644 index 000000000..406a9d22f --- /dev/null +++ b/packages/components/src/Image/tests/jest/SvgImage.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@hopper-ui/test-utils"; +import { type ComponentProps, createRef, forwardRef } from "react"; + +import { SvgImage } from "../../src/SvgImage.tsx"; +import { SvgImageContext } from "../../src/SvgImageContext.ts"; + +const BasicSvg = forwardRef>((props, ref) => { + return ( + + + + + + ); +}); + +describe("SvgImage", () => { + it("should render with default class", () => { + render(); + + const element = screen.getByRole("img"); + expect(element).toHaveClass("hop-SvgImage"); + }); + + it("should support custom class", () => { + render(); + + const element = screen.getByRole("img"); + expect(element).toHaveClass("hop-SvgImage"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(); + + const element = screen.getByRole("img"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(); + + const element = screen.getByRole("img"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + + + ); + + const element = screen.getByRole("img"); + expect(element).toHaveAttribute("slot", "test"); + expect(element).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof SVGSVGElement).toBeTruthy(); + }); +}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 82700efca..7ba440965 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -12,6 +12,8 @@ export * from "./Header/index.ts"; export * from "./HelperMessage/index.ts"; export * from "./HopperProvider/index.ts"; export * from "./IconList/index.ts"; +export * from "./IllustratedMessage/index.ts"; +export * from "./Image/index.ts"; export * from "./inputs/index.ts"; export * from "./layout/index.ts"; export * from "./Link/index.ts";