From 1bd6a20c71976d61783c00b71a498ea7b48d6a2a Mon Sep 17 00:00:00 2001 From: Edward Granger Date: Fri, 23 Feb 2024 16:37:04 -0500 Subject: [PATCH] fix: i don't know how dialog went mising --- ui/dialog/CHANGELOG.md | 26 ++ ui/dialog/README.md | 9 + ui/dialog/package.json | 52 ++++ ui/dialog/src/Dialog.tsx | 39 +++ ui/dialog/src/DialogBody.test.tsx | 25 ++ ui/dialog/src/DialogBody.tsx | 61 +++++ ui/dialog/src/DialogClose.test.tsx | 23 ++ ui/dialog/src/DialogClose.tsx | 56 ++++ ui/dialog/src/DialogContent.test.tsx | 15 ++ ui/dialog/src/DialogContent.tsx | 130 ++++++++++ ui/dialog/src/DialogDescription.test.tsx | 17 ++ ui/dialog/src/DialogDescription.tsx | 41 +++ ui/dialog/src/DialogFooter.test.tsx | 10 + ui/dialog/src/DialogFooter.tsx | 38 +++ ui/dialog/src/DialogHeader.test.tsx | 10 + ui/dialog/src/DialogHeader.tsx | 29 +++ ui/dialog/src/DialogOverlay.test.tsx | 15 ++ ui/dialog/src/DialogOverlay.tsx | 107 ++++++++ ui/dialog/src/DialogPortal.test.tsx | 20 ++ ui/dialog/src/DialogPortal.tsx | 17 ++ ui/dialog/src/DialogRoot.test.tsx | 10 + ui/dialog/src/DialogRoot.tsx | 43 ++++ ui/dialog/src/DialogTitle.test.tsx | 15 ++ ui/dialog/src/DialogTitle.tsx | 37 +++ ui/dialog/src/DialogTrigger.test.tsx | 15 ++ ui/dialog/src/DialogTrigger.tsx | 23 ++ ui/dialog/src/index.ts | 12 + ui/dialog/src/play.stories.tsx | 315 +++++++++++++++++++++++ 28 files changed, 1210 insertions(+) create mode 100644 ui/dialog/CHANGELOG.md create mode 100644 ui/dialog/README.md create mode 100644 ui/dialog/package.json create mode 100644 ui/dialog/src/Dialog.tsx create mode 100644 ui/dialog/src/DialogBody.test.tsx create mode 100644 ui/dialog/src/DialogBody.tsx create mode 100644 ui/dialog/src/DialogClose.test.tsx create mode 100644 ui/dialog/src/DialogClose.tsx create mode 100644 ui/dialog/src/DialogContent.test.tsx create mode 100644 ui/dialog/src/DialogContent.tsx create mode 100644 ui/dialog/src/DialogDescription.test.tsx create mode 100644 ui/dialog/src/DialogDescription.tsx create mode 100644 ui/dialog/src/DialogFooter.test.tsx create mode 100644 ui/dialog/src/DialogFooter.tsx create mode 100644 ui/dialog/src/DialogHeader.test.tsx create mode 100644 ui/dialog/src/DialogHeader.tsx create mode 100644 ui/dialog/src/DialogOverlay.test.tsx create mode 100644 ui/dialog/src/DialogOverlay.tsx create mode 100644 ui/dialog/src/DialogPortal.test.tsx create mode 100644 ui/dialog/src/DialogPortal.tsx create mode 100644 ui/dialog/src/DialogRoot.test.tsx create mode 100644 ui/dialog/src/DialogRoot.tsx create mode 100644 ui/dialog/src/DialogTitle.test.tsx create mode 100644 ui/dialog/src/DialogTitle.tsx create mode 100644 ui/dialog/src/DialogTrigger.test.tsx create mode 100644 ui/dialog/src/DialogTrigger.tsx create mode 100644 ui/dialog/src/index.ts create mode 100644 ui/dialog/src/play.stories.tsx diff --git a/ui/dialog/CHANGELOG.md b/ui/dialog/CHANGELOG.md new file mode 100644 index 000000000..39e032994 --- /dev/null +++ b/ui/dialog/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [1.22.0](https://github.com/washingtonpost/wpds-ui-kit/compare/v1.21.0...v1.22.0) (2024-02-15) + +**Note:** Version bump only for package @washingtonpost/wpds-dialog + +# [1.21.0](https://github.com/washingtonpost/wpds-ui-kit/compare/v1.20.0...v1.21.0) (2024-02-07) + +**Note:** Version bump only for package @washingtonpost/wpds-dialog + +# [1.20.0](https://github.com/washingtonpost/wpds-ui-kit/compare/v1.19.0...v1.20.0) (2024-01-24) + +**Note:** Version bump only for package @washingtonpost/wpds-dialog + +# [1.19.0](https://github.com/washingtonpost/wpds-ui-kit/compare/v1.18.0...v1.19.0) (2024-01-10) + +**Note:** Version bump only for package @washingtonpost/wpds-dialog + +# [1.18.0](https://github.com/washingtonpost/wpds-ui-kit/compare/v1.17.0...v1.18.0) (2023-12-13) + +### Features + +- add Dialog component ([15d6206](https://github.com/washingtonpost/wpds-ui-kit/commit/15d6206d6287bb3eee3c9f0af8ff57a0bf917998)) diff --git a/ui/dialog/README.md b/ui/dialog/README.md new file mode 100644 index 000000000..b0d8147c8 --- /dev/null +++ b/ui/dialog/README.md @@ -0,0 +1,9 @@ +# Dialog + +```jsx +import { Dialog } from "@washingtonpost/wpds-ui-kit"; + +function Component() { + return ; +} +``` diff --git a/ui/dialog/package.json b/ui/dialog/package.json new file mode 100644 index 000000000..afcd895f2 --- /dev/null +++ b/ui/dialog/package.json @@ -0,0 +1,52 @@ +{ + "name": "@washingtonpost/wpds-dialog", + "version": "1.22.0", + "description": "WPDS Dialog", + "author": "WPDS Support ", + "homepage": "https://github.com/washingtonpost/wpds-ui-kit#readme", + "license": "MIT", + "source": "src/index.ts", + "main": "dist/index.js", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "src" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/washingtonpost/wpds-ui-kit.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "build": "tsup src/index.ts --loader .ts=tsx --minify --format esm,cjs --dts --sourcemap --legacy-output --external react", + "dev": "tsup src/index.ts --format esm,cjs --watch --dts --legacy-output --external react", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + }, + "bugs": { + "url": "https://github.com/washingtonpost/wpds-ui-kit/issues" + }, + "devDependencies": { + "tsup": "5.11.13", + "typescript": "4.5.5" + }, + "peerDependencies": { + "@washingtonpost/wpds-theme": "*", + "react": "^16.8.6 || ^17.0.2" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-use-controllable-state": "^1.0.1", + "@washingtonpost/wpds-assets": "^1.23.1", + "@washingtonpost/wpds-button": "1.22.0", + "@washingtonpost/wpds-icon": "1.22.0", + "@washingtonpost/wpds-theme": "1.22.0", + "react-transition-group": "^4.4.5" + }, + "gitHead": "dddd34ee2494be2d91fe5671db3291060097b02a" +} diff --git a/ui/dialog/src/Dialog.tsx b/ui/dialog/src/Dialog.tsx new file mode 100644 index 000000000..9f0f4b15e --- /dev/null +++ b/ui/dialog/src/Dialog.tsx @@ -0,0 +1,39 @@ +import { DialogRoot } from "./DialogRoot"; +import { DialogContent } from "./DialogContent"; +import { DialogTrigger } from "./DialogTrigger"; +import { DialogPortal } from "./DialogPortal"; +import { DialogOverlay } from "./DialogOverlay"; +import { DialogTitle } from "./DialogTitle"; +import { DialogDescription } from "./DialogDescription"; +import { DialogClose } from "./DialogClose"; +import { DialogHeader } from "./DialogHeader"; +import { DialogBody } from "./DialogBody"; +import { DialogFooter } from "./DialogFooter"; + +export type DialogProps = { + Root: typeof DialogRoot; + Content: typeof DialogContent; + Trigger: typeof DialogTrigger; + Portal: typeof DialogPortal; + Overlay: typeof DialogOverlay; + Title: typeof DialogTitle; + Description: typeof DialogDescription; + Close: typeof DialogClose; + Header: typeof DialogHeader; + Body: typeof DialogBody; + Footer: typeof DialogFooter; +}; + +export const Dialog: DialogProps = { + Root: DialogRoot, + Content: DialogContent, + Trigger: DialogTrigger, + Portal: DialogPortal, + Overlay: DialogOverlay, + Title: DialogTitle, + Description: DialogDescription, + Close: DialogClose, + Header: DialogHeader, + Body: DialogBody, + Footer: DialogFooter, +}; diff --git a/ui/dialog/src/DialogBody.test.tsx b/ui/dialog/src/DialogBody.test.tsx new file mode 100644 index 000000000..cf78c79d4 --- /dev/null +++ b/ui/dialog/src/DialogBody.test.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogBody } from "./DialogBody"; + +const ComponentWrapper = () => { + const bodyRef = React.useRef(null); + return {bodyRef.current && `ref`}; +}; + +describe("DialogBody", () => { + test("renders visibly into the document", () => { + render(test); + expect(screen.getByText("test")).toBeVisible(); + }); + test("accepts callback ref", () => { + const callbackRef = jest.fn(); + render(); + expect(callbackRef).toHaveBeenCalled(); + }); + test("accepts ref", () => { + const { rerender } = render(); + rerender(); + expect(screen.getByText("ref")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogBody.tsx b/ui/dialog/src/DialogBody.tsx new file mode 100644 index 000000000..4fffd7c01 --- /dev/null +++ b/ui/dialog/src/DialogBody.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { styled, theme } from "@washingtonpost/wpds-theme"; + +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogBody"; + +const StyledBody = styled("div", { + color: theme.colors.primary, + fontFamily: theme.fonts.meta, + fontSize: theme.fontSizes["100"], + fontWeight: theme.fontWeights.light, + lineHeight: theme.lineHeights["125"], + gridArea: "body", + maxHeight: "100%", + overflowY: "auto", + variants: { + isOverflow: { + true: { + marginInlineEnd: `calc(-1 * ${theme.space["150"]})`, + paddingInlineEnd: theme.space["125"], + }, + }, + }, +}); + +export type DialogBodyProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & React.ComponentPropsWithRef; + +export const DialogBody = React.forwardRef( + ({ children, ...props }: DialogBodyProps, ref) => { + const internalRef = React.useRef(null); + + React.useEffect(() => { + if (!ref) return; + typeof ref === "function" + ? ref(internalRef.current) + : (ref.current = internalRef.current); + }, [ref, internalRef]); + + const [isOverflow, setIsOverflow] = React.useState(false); + + React.useEffect(() => { + if (!internalRef.current) return; + const element = internalRef.current; + setIsOverflow(element.scrollHeight > element.clientHeight); + }, [children, setIsOverflow]); + + return ( + + {children} + + ); + } +); + +DialogBody.displayName = NAME; diff --git a/ui/dialog/src/DialogClose.test.tsx b/ui/dialog/src/DialogClose.test.tsx new file mode 100644 index 000000000..1d4c53cdf --- /dev/null +++ b/ui/dialog/src/DialogClose.test.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogClose } from "./DialogClose"; + +const customRender = (component) => { + return render({component}); +}; + +describe("DialogClose", () => { + test("renders visibly into the document", () => { + customRender(); + expect(screen.getByRole("button")).toBeVisible(); + }); + test("honors asChild prop", () => { + customRender( + + + + ); + expect(screen.getByText("Cancel")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogClose.tsx b/ui/dialog/src/DialogClose.tsx new file mode 100644 index 000000000..ee015c32e --- /dev/null +++ b/ui/dialog/src/DialogClose.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { styled, theme } from "@washingtonpost/wpds-theme"; +import { Button } from "@washingtonpost/wpds-button"; +import { Icon } from "@washingtonpost/wpds-icon"; +import { Close } from "@washingtonpost/wpds-assets"; + +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogClose"; + +const StyledClose = styled(DialogPrimitive.Close, { + variants: { + main: { + true: { + position: "absolute", + insetBlockStart: theme.space["100"], + insetInlineEnd: theme.space["100"], + }, + }, + }, +}); + +export type DialogCloseProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & React.ComponentPropsWithRef; + +export const DialogClose = React.forwardRef< + HTMLButtonElement, + DialogCloseProps +>(({ ...props }, ref) => { + if (props.asChild) { + return ; + } else { + return ( + + + + ); + } +}); + +DialogClose.displayName = NAME; diff --git a/ui/dialog/src/DialogContent.test.tsx b/ui/dialog/src/DialogContent.test.tsx new file mode 100644 index 000000000..7ac9b8f3e --- /dev/null +++ b/ui/dialog/src/DialogContent.test.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogContent } from "./DialogContent"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogContent", () => { + test("renders visibly into the document", () => { + customRender(, { defaultOpen: true }); + expect(screen.getByRole("dialog")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogContent.tsx b/ui/dialog/src/DialogContent.tsx new file mode 100644 index 000000000..7ede5db20 --- /dev/null +++ b/ui/dialog/src/DialogContent.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { theme, styled } from "@washingtonpost/wpds-theme"; +import { CSSTransition } from "react-transition-group"; +import { DialogContext } from "./DialogRoot"; + +import type { DialogContentProps as RadixDialogContentProps } from "@radix-ui/react-dialog"; +import type { StandardLonghandProperties } from "@stitches/react/types/css"; +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogContent"; + +const StyledContent = styled(DialogPrimitive.Content, { + borderRadius: theme.radii["025"], + boxShadow: theme.shadows["300"], + color: theme.colors.primary, + containerType: "inline-size", + display: "grid", + gridTemplateAreas: "'header' 'body' 'footer'", + gridTemplateRows: "auto 1fr auto", + padding: theme.space["150"], + position: "fixed", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + "&.wpds-dialog-content-enter, &.wpds-dialog-content-appear": { + transform: "translate(-50%, -47%)", + opacity: 0, + }, + "&.wpds-dialog-content-enter-active, &.wpds-dialog-content-appear-active": { + transform: "translate(-50%, -50%)", + opacity: 1, + transition: ` + transform ${theme.transitions.normal} ${theme.transitions.inOut}, + opacity ${theme.transitions.normal} ${theme.transitions.inOut} + `, + "@reducedMotion": { + transition: "none", + }, + }, + "&.wpds-dialog-content-exit": { + transform: "translate(-50%, -50%)", + opacity: 1, + }, + "&.wpds-dialog-content-exit-active": { + transform: "translate(-50%, -50%) scale(0.97)", + opacity: 0, + transition: ` + transform ${theme.transitions.fast} ${theme.transitions.inOut}, + opacity ${theme.transitions.fast} ${theme.transitions.inOut} + `, + "@reducedMotion": { + transition: "none", + }, + }, +}); + +export type DialogContentProps = { + /** Css background color of content*/ + backgroundColor?: + | StandardLonghandProperties["backgroundColor"] + | typeof theme.colors[keyof typeof theme.colors]; + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; + /** Width in any valid css string */ + width?: string; + /** Height in any valid css string */ + height?: string; + /** Css z-index */ + zIndex?: + | StandardLonghandProperties["zIndex"] + | typeof theme.zIndices[keyof typeof theme.zIndices]; +} & RadixDialogContentProps; + +export const DialogContent = React.forwardRef< + HTMLDivElement, + DialogContentProps +>( + ( + { + backgroundColor = theme.colors.secondary, + children, + css, + forceMount = true, + width = "500px", + height = "300px", + zIndex = theme.zIndices.offer, + ...props + }: DialogContentProps, + ref + ) => { + const { open } = React.useContext(DialogContext); + + const internalRef = React.useRef(null); + React.useEffect(() => { + if (!ref) return; + typeof ref === "function" + ? ref(internalRef.current) + : (ref.current = internalRef.current); + }, [ref, internalRef]); + + return ( + + + {children} + + + ); + } +); + +DialogContent.displayName = NAME; diff --git a/ui/dialog/src/DialogDescription.test.tsx b/ui/dialog/src/DialogDescription.test.tsx new file mode 100644 index 000000000..74748e2bd --- /dev/null +++ b/ui/dialog/src/DialogDescription.test.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogDescription } from "./DialogDescription"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogDescription", () => { + test("renders visibly into the document", () => { + customRender(test, { + defaultOpen: true, + }); + expect(screen.getByText("test")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogDescription.tsx b/ui/dialog/src/DialogDescription.tsx new file mode 100644 index 000000000..3a989bf64 --- /dev/null +++ b/ui/dialog/src/DialogDescription.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { theme, styled } from "@washingtonpost/wpds-theme"; + +import type { DialogDescriptionProps as RadixDialogDescriptionProps } from "@radix-ui/react-dialog"; +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogDescription"; + +const StyledDescription = styled(DialogPrimitive.Description, { + color: theme.colors.primary, + fontFamily: theme.fonts.meta, + fontSize: theme.fontSizes["100"], + fontWeight: theme.fontWeights.light, + lineHeight: theme.lineHeights["125"], + marginBlockStart: 0, + marginBlockEnd: theme.space["125"], + "&:last-child": { + marginBlockEnd: 0, + }, +}); + +export type DialogDescriptionProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & RadixDialogDescriptionProps; + +export const DialogDescription = React.forwardRef< + HTMLParagraphElement, + DialogDescriptionProps +>(({ children, ...props }: DialogDescriptionProps, ref) => { + return ( + + {children} + + ); +}); + +DialogDescription.displayName = NAME; diff --git a/ui/dialog/src/DialogFooter.test.tsx b/ui/dialog/src/DialogFooter.test.tsx new file mode 100644 index 000000000..ab7b268f3 --- /dev/null +++ b/ui/dialog/src/DialogFooter.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogFooter } from "./DialogFooter"; + +describe("DialogFooter", () => { + test("renders visibly into the document", () => { + render(); + expect(screen.getByRole("contentinfo")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogFooter.tsx b/ui/dialog/src/DialogFooter.tsx new file mode 100644 index 000000000..132a9dd62 --- /dev/null +++ b/ui/dialog/src/DialogFooter.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { styled, theme } from "@washingtonpost/wpds-theme"; + +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogFooter"; + +const StyledFooter = styled("footer", { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.space["050"], + "@container (max-width: 350px)": { + flexDirection: "column-reverse", + alignItems: "stretch", + }, + gridArea: "footer", + marginBlockStart: theme.space["150"], +}); + +export type DialogFooterProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & React.ComponentPropsWithRef; + +export const DialogFooter = React.forwardRef( + ({ children, ...props }: DialogFooterProps, ref) => { + return ( + + {children} + + ); + } +); + +DialogFooter.displayName = NAME; diff --git a/ui/dialog/src/DialogHeader.test.tsx b/ui/dialog/src/DialogHeader.test.tsx new file mode 100644 index 000000000..e2c2e62b2 --- /dev/null +++ b/ui/dialog/src/DialogHeader.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogHeader } from "./DialogHeader"; + +describe("DialogHeader", () => { + test("renders visibly into the document", () => { + render(); + expect(screen.getByRole("banner")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogHeader.tsx b/ui/dialog/src/DialogHeader.tsx new file mode 100644 index 000000000..3889b71d8 --- /dev/null +++ b/ui/dialog/src/DialogHeader.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { styled } from "@washingtonpost/wpds-theme"; + +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogHeader"; + +const StyledHeader = styled("header", { + gridArea: "header", +}); + +export type DialogHeaderProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & React.ComponentPropsWithRef; + +export const DialogHeader = React.forwardRef( + ({ children, ...props }: DialogHeaderProps, ref) => { + return ( + + {children} + + ); + } +); + +DialogHeader.displayName = NAME; diff --git a/ui/dialog/src/DialogOverlay.test.tsx b/ui/dialog/src/DialogOverlay.test.tsx new file mode 100644 index 000000000..dbf74205e --- /dev/null +++ b/ui/dialog/src/DialogOverlay.test.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogOverlay } from "./DialogOverlay"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogOverlay", () => { + test("renders visibly into the document", () => { + customRender(, { defaultOpen: true }); + expect(screen.getByTestId("test")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogOverlay.tsx b/ui/dialog/src/DialogOverlay.tsx new file mode 100644 index 000000000..1335f232a --- /dev/null +++ b/ui/dialog/src/DialogOverlay.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { CSSTransition } from "react-transition-group"; +import { theme, styled } from "@washingtonpost/wpds-theme"; +import { DialogContext } from "./DialogRoot"; + +import type { DialogOverlayProps as RadixDialogOverlayProps } from "@radix-ui/react-dialog"; +import type * as WPDS from "@washingtonpost/wpds-theme"; +import type { StandardLonghandProperties } from "@stitches/react/types/css"; + +const NAME = "DialogOverlay"; + +const overlayTransition = `opacity ${theme.transitions.normal} ${theme.transitions.inOut}`; + +const StyledOverlay = styled(DialogPrimitive.Overlay, { + backgroundColor: theme.colors.alpha50, + inset: "0", + position: "fixed", + "&.wpds-dialog-overlay-enter, &.wpds-dialog-overlay-appear": { + opacity: 0, + }, + "&.wpds-dialog-overlay-enter-active, &.wpds-dialog-overlay-appear-active": { + opacity: 1, + transition: overlayTransition, + "@reducedMotion": { + transition: "none", + }, + }, + "&.wpds-dialog-overlay-exit": { + opacity: 1, + }, + "&.wpds-dialog-overlay-exit-active": { + opacity: 0, + transition: overlayTransition, + "@reducedMotion": { + transition: "none", + }, + }, +}); + +export type DialogOverlayProps = { + /** Css background color of overlay*/ + backgroundColor?: + | StandardLonghandProperties["backgroundColor"] + | typeof theme.colors[keyof typeof theme.colors]; + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; + /** Css z-index of overlay */ + zIndex?: + | StandardLonghandProperties["zIndex"] + | typeof theme.zIndices[keyof typeof theme.zIndices]; +} & RadixDialogOverlayProps; + +export const DialogOverlay = React.forwardRef< + HTMLDivElement, + DialogOverlayProps +>( + ( + { + backgroundColor = theme.colors.alpha50, + children, + css, + zIndex = theme.zIndices.offer, + forceMount = true, + ...props + }: DialogOverlayProps, + ref + ) => { + const { open } = React.useContext(DialogContext); + + const internalRef = React.useRef(null); + React.useEffect(() => { + if (!ref) return; + typeof ref === "function" + ? ref(internalRef.current) + : (ref.current = internalRef.current); + }, [ref, internalRef]); + + return ( + + + {children} + + + ); + } +); + +DialogOverlay.displayName = NAME; diff --git a/ui/dialog/src/DialogPortal.test.tsx b/ui/dialog/src/DialogPortal.test.tsx new file mode 100644 index 000000000..9dcdf9390 --- /dev/null +++ b/ui/dialog/src/DialogPortal.test.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogPortal } from "./DialogPortal"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogPortal", () => { + test("renders visibly into the document", () => { + customRender( + + test + , + { defaultOpen: true } + ); + expect(screen.getByText("test")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogPortal.tsx b/ui/dialog/src/DialogPortal.tsx new file mode 100644 index 000000000..4fc739fc6 --- /dev/null +++ b/ui/dialog/src/DialogPortal.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +import type { DialogPortalProps as RadixDialogPortalProps } from "@radix-ui/react-dialog"; + +const NAME = "DialogPortal"; + +export type DialogPortalProps = RadixDialogPortalProps; + +export const DialogPortal = ({ + forceMount = true, + ...props +}: DialogPortalProps) => ( + +); + +DialogPortal.displayName = NAME; diff --git a/ui/dialog/src/DialogRoot.test.tsx b/ui/dialog/src/DialogRoot.test.tsx new file mode 100644 index 000000000..4ca87e55d --- /dev/null +++ b/ui/dialog/src/DialogRoot.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; + +describe("DialogRoot", () => { + test("renders visibly into the document", () => { + render(test); + expect(screen.getByText("test")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogRoot.tsx b/ui/dialog/src/DialogRoot.tsx new file mode 100644 index 000000000..e8431ea49 --- /dev/null +++ b/ui/dialog/src/DialogRoot.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { useControllableState } from "@radix-ui/react-use-controllable-state"; + +import type { DialogProps as RadixDialogProps } from "@radix-ui/react-dialog"; + +type DialogContextInterface = { + open: boolean | undefined; +}; + +export const DialogContext = React.createContext({} as DialogContextInterface); + +const NAME = "DialogRoot"; + +export type DialogRootProps = RadixDialogProps; + +export const DialogRoot = ({ + open: openProp, + defaultOpen, + onOpenChange, + ...props +}: DialogRootProps) => { + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + return ( + + setOpen(val)} + {...props} + /> + + ); +}; + +DialogRoot.displayName = NAME; diff --git a/ui/dialog/src/DialogTitle.test.tsx b/ui/dialog/src/DialogTitle.test.tsx new file mode 100644 index 000000000..5ab9d782f --- /dev/null +++ b/ui/dialog/src/DialogTitle.test.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogTitle } from "./DialogTitle"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogTitle", () => { + test("renders visibly into the document", () => { + customRender(, { defaultOpen: true }); + expect(screen.getByRole("heading")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogTitle.tsx b/ui/dialog/src/DialogTitle.tsx new file mode 100644 index 000000000..7466cc187 --- /dev/null +++ b/ui/dialog/src/DialogTitle.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { theme, styled } from "@washingtonpost/wpds-theme"; + +import type { DialogTitleProps as RadixDialogTitleProps } from "@radix-ui/react-dialog"; +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogTitle"; + +const StyledTitle = styled(DialogPrimitive.Title, { + color: theme.colors.primary, + fontFamily: theme.fonts.meta, + fontSize: theme.fontSizes["125"], + fontWeight: theme.fontWeights.bold, + marginBlockStart: 0, + marginBlockEnd: theme.space["150"], +}); + +export type DialogTitleProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & RadixDialogTitleProps; + +export const DialogTitle = React.forwardRef< + HTMLHeadingElement, + DialogTitleProps +>(({ children, ...props }: DialogTitleProps, ref) => { + return ( + + {children} + + ); +}); + +DialogTitle.displayName = NAME; diff --git a/ui/dialog/src/DialogTrigger.test.tsx b/ui/dialog/src/DialogTrigger.test.tsx new file mode 100644 index 000000000..3e459a9cc --- /dev/null +++ b/ui/dialog/src/DialogTrigger.test.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { DialogRoot } from "./DialogRoot"; +import { DialogTrigger } from "./DialogTrigger"; + +const customRender = (component, rootProps = {}) => { + return render({component}); +}; + +describe("DialogTrigger", () => { + test("renders visibly into the document", () => { + customRender(, { defaultOpen: true }); + expect(screen.getByRole("button")).toBeVisible(); + }); +}); diff --git a/ui/dialog/src/DialogTrigger.tsx b/ui/dialog/src/DialogTrigger.tsx new file mode 100644 index 000000000..29a88f20a --- /dev/null +++ b/ui/dialog/src/DialogTrigger.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { styled } from "@washingtonpost/wpds-theme"; + +import type * as WPDS from "@washingtonpost/wpds-theme"; + +const NAME = "DialogTrigger"; + +const StyledTrigger = styled(DialogPrimitive.Trigger, {}); + +export type DialogTriggerProps = { + /** Any React node may be used as a child */ + children?: React.ReactNode; + /** Override CSS */ + css?: WPDS.CSS; +} & React.ComponentPropsWithRef; + +export const DialogTrigger = React.forwardRef< + HTMLButtonElement, + DialogTriggerProps +>(({ ...props }, ref) => ); + +DialogTrigger.displayName = NAME; diff --git a/ui/dialog/src/index.ts b/ui/dialog/src/index.ts new file mode 100644 index 000000000..ec26b20ad --- /dev/null +++ b/ui/dialog/src/index.ts @@ -0,0 +1,12 @@ +export * from "./Dialog"; +export * from "./DialogRoot"; +export * from "./DialogContent"; +export * from "./DialogTrigger"; +export * from "./DialogPortal"; +export * from "./DialogOverlay"; +export * from "./DialogTitle"; +export * from "./DialogDescription"; +export * from "./DialogClose"; +export * from "./DialogHeader"; +export * from "./DialogBody"; +export * from "./DialogFooter"; diff --git a/ui/dialog/src/play.stories.tsx b/ui/dialog/src/play.stories.tsx new file mode 100644 index 000000000..45f47388a --- /dev/null +++ b/ui/dialog/src/play.stories.tsx @@ -0,0 +1,315 @@ +import * as React from "react"; +import { userEvent, waitFor, within } from "@storybook/testing-library"; +import { expect } from "@storybook/jest"; +import { Dialog } from "./Dialog"; +import { Button, styled, theme } from "@washingtonpost/wpds-ui-kit"; + +import type { ComponentMeta, ComponentStory } from "@storybook/react"; + +export default { + title: "Dialog", + component: Dialog.Root, + subcomponents: { + Content: Dialog.Content, + Trigger: Dialog.Trigger, + Portal: Dialog.Portal, + Overlay: Dialog.Overlay, + Title: Dialog.Title, + Description: Dialog.Description, + }, +} as ComponentMeta; + +const DialogContainer = styled("div", { + position: "relative", + height: "100vh", + width: "50vw", + marginBlock: "-32px", + marginInlineStart: "-16px", + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +const Template: ComponentStory = (args) => { + const [container, setContainer] = React.useState(null); + + return ( +
+ + + + + + + + Dialog Title + + Descriptive text of dialog content + + + + + +
+ ); +}; + +export const Default = Template.bind({}); + +Default.args = {}; + +const ContentTemplate: ComponentStory = (args) => { + const [container, setContainer] = React.useState(null); + + return ( + + + + + + + + + + Dialog Title + + +

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. + Tempora cupiditate possimus aliquid natus cumque? Ratione minus + exercitationem consequuntur quis dolor ut possimus earum + officiis itaque culpa eveniet vero, laboriosam sit! +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. + Tempora cupiditate possimus aliquid natus cumque? Ratione minus + exercitationem consequuntur quis dolor ut possimus earum + officiis itaque culpa eveniet vero, laboriosam sit! +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. + Tempora cupiditate possimus aliquid natus cumque? Ratione minus + exercitationem consequuntur quis dolor ut possimus earum + officiis itaque culpa eveniet vero, laboriosam sit! +

+
+ + + + + + +
+
+
+
+ ); +}; + +export const Content = ContentTemplate.bind({}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ContentBackgroundColorTemplate: any = (args) => { + const [container, setContainer] = React.useState(null); + + return ( + + + + + + + + ); +}; + +export const ContentBackgroundColor = ContentBackgroundColorTemplate.bind({}); + +ContentBackgroundColor.argTypes = { + backgroundColor: { + control: { type: "color" }, + }, +}; + +ContentBackgroundColor.args = { + // eslint-disable-next-line @washingtonpost/wpds/theme-colors + backgroundColor: theme.colors.blue500.value, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const OverlayBackgroundColorTemplate: any = (args) => { + const [container, setContainer] = React.useState(null); + + return ( + + + + + + + + ); +}; + +export const OverlayBackgroundColor = OverlayBackgroundColorTemplate.bind({}); + +OverlayBackgroundColor.argTypes = { + backgroundColor: { + control: { type: "color" }, + }, +}; + +OverlayBackgroundColor.args = { + // eslint-disable-next-line @washingtonpost/wpds/theme-colors + backgroundColor: theme.colors.green500.value, +}; + +const SmallTemplate: ComponentStory = (args) => { + const [container, setContainer] = React.useState(null); + + return ( + + + + + + + Dialog Title + + +

Lorem ipsum dolor sit, amet consectetur adipisicing elit.

+
+ + + + + + +
+
+
+
+ ); +}; + +export const Small = SmallTemplate.bind({}); + +const InteractionsTemplate: ComponentStory = () => { + const [container, setContainer] = React.useState(null); + + return ( + + + Open + + + + + + Dialog Title + + +

Lorem ipsum

+
+ + + + + +
+
+
+
+ ); +}; + +export const Interactions = InteractionsTemplate.bind({}); + +Interactions.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const open = canvas.getByRole("button"); + await userEvent.click(open); + await waitFor(() => expect(canvas.getByTestId("close-button")).toHaveFocus()); + await userEvent.click(canvas.getByTestId("close-button")); + await waitFor(() => expect(open).toHaveFocus()); + await userEvent.click(open); + await waitFor(() => expect(canvas.getByText("Cancel")).toBeVisible()); + await userEvent.click(canvas.getByText("Cancel")); + await waitFor(() => expect(open).toHaveFocus()); + await userEvent.click(open); + await waitFor(() => expect(canvas.getByTestId("overlay")).toBeVisible()); + await userEvent.click(canvas.getByTestId("overlay")); + await waitFor(() => expect(open).toHaveFocus()); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ResponsiveInteractionsTemplate: any = () => { + const [container, setContainer] = React.useState(null); + + return ( + + + Open + + + + + + Dialog Title + + +

Lorem ipsum

+
+ + + + + +
+
+
+
+ ); +}; + +export const ResponsiveInteractions = ResponsiveInteractionsTemplate.bind({}); + +ResponsiveInteractions.parameters = { + viewport: { + defaultViewport: "small", + viewports: { + small: { + name: "Small", + styles: { + height: "590px", + width: "767px", + }, + type: "mobile", + }, + }, + }, + chromatic: { + modes: { + mobile: { viewport: "small" }, + }, + }, +}; + +// Function to emulate pausing between interactions +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +ResponsiveInteractions.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await sleep(500); + await expect(canvas.getByRole("dialog")).toHaveStyle("width: 300px"); +};