diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-api.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-api.tsx index 1d6a147687..74968ecc42 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-api.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-api.tsx @@ -1,10 +1,10 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { formatNumber } from "@/lib/fmt"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Lock } from "@unkey/icons"; +import { DialogContainer } from "@unkey/ui"; import { Button, Input, SettingCard } from "@unkey/ui"; import { useRouter } from "next/navigation"; import type React from "react"; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-protection.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-protection.tsx index d504ddb351..49cdcd3ad2 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-protection.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/components/delete-protection.tsx @@ -1,9 +1,9 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowUpRight, TriangleWarning2 } from "@unkey/icons"; +import { DialogContainer } from "@unkey/ui"; import { InlineLink, Input, SettingCard } from "@unkey/ui"; import { Button } from "@unkey/ui"; import { useRouter } from "next/navigation"; diff --git a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx b/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx index 2e65ecb761..e73ab48c61 100644 --- a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx +++ b/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx @@ -1,10 +1,10 @@ "use client"; import { revalidateTag } from "@/app/actions"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { tags } from "@/lib/cache"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, FormInput, FormTextarea } from "@unkey/ui"; import { validation } from "@unkey/validation"; import { useRouter } from "next/navigation"; diff --git a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx index fa7e42eca2..1a52941176 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx @@ -1,9 +1,9 @@ "use client"; import { revalidate } from "@/app/actions"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, Input } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useState } from "react"; diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx index 4711fa8417..69f92bf0c6 100644 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx @@ -1,8 +1,8 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, Input } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useState } from "react"; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx index 5473c873c0..2adff0d945 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/delete-dialog.tsx @@ -1,9 +1,9 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, Input } from "@unkey/ui"; import type { PropsWithChildren } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx index 508f65b656..d7f3c8f345 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/identifier-dialog.tsx @@ -1,13 +1,20 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { Badge } from "@/components/ui/badge"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CircleInfo } from "@unkey/icons"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { Button, FormInput } from "@unkey/ui"; +import { + Button, + DialogContainer, + FormInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; import type { PropsWithChildren } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx index 70f444ab8a..0c0476b5ca 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx @@ -1,11 +1,11 @@ "use client"; import { revalidateTag } from "@/app/actions"; -import { DialogContainer } from "@/components/dialog-container"; import { toast } from "@/components/ui/toaster"; import { tags } from "@/lib/cache"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, Input } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx b/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx index 456eb0d26d..f37412e59c 100644 --- a/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx +++ b/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx @@ -1,11 +1,11 @@ "use client"; import { revalidate } from "@/app/actions"; -import { DialogContainer } from "@/components/dialog-container"; import { NavbarActionButton } from "@/components/navigation/action-button"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DialogContainer } from "@unkey/ui"; import { Button, FormInput } from "@unkey/ui"; import { Plus } from "lucide-react"; import { useRouter } from "next/navigation"; diff --git a/apps/dashboard/app/(app)/settings/billing/components/confirmation.tsx b/apps/dashboard/app/(app)/settings/billing/components/confirmation.tsx index caa205ec08..aaa20eaea1 100644 --- a/apps/dashboard/app/(app)/settings/billing/components/confirmation.tsx +++ b/apps/dashboard/app/(app)/settings/billing/components/confirmation.tsx @@ -1,6 +1,6 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; +import { DialogContainer } from "@unkey/ui"; import { Button } from "@unkey/ui"; import { useState } from "react"; diff --git a/apps/dashboard/app/(app)/settings/team/invite.tsx b/apps/dashboard/app/(app)/settings/team/invite.tsx index 8691191bdc..09c2a09bca 100644 --- a/apps/dashboard/app/(app)/settings/team/invite.tsx +++ b/apps/dashboard/app/(app)/settings/team/invite.tsx @@ -1,5 +1,4 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; import { Form, FormControl, @@ -14,8 +13,15 @@ import { toast } from "@/components/ui/toaster"; import type { AuthenticatedUser, Organization } from "@/lib/auth/types"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { Button } from "@unkey/ui"; +import { + Button, + DialogContainer, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; import { Plus } from "lucide-react"; import type React from "react"; import { useState } from "react"; diff --git a/apps/dashboard/app/auth/sign-in/org-selector.tsx b/apps/dashboard/app/auth/sign-in/org-selector.tsx index 81eb794bf3..fd7dd46918 100644 --- a/apps/dashboard/app/auth/sign-in/org-selector.tsx +++ b/apps/dashboard/app/auth/sign-in/org-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { DialogContainer } from "@/components/dialog-container"; +import { DialogContainer } from "@unkey/ui"; import type { Organization } from "@/lib/auth/types"; import { Button } from "@unkey/ui"; diff --git a/apps/engineering/content/design/components/dialog-container.example.tsx b/apps/engineering/content/design/components/dialog-container.example.tsx new file mode 100644 index 0000000000..b13088f160 --- /dev/null +++ b/apps/engineering/content/design/components/dialog-container.example.tsx @@ -0,0 +1,63 @@ +"use client"; +import { RenderComponentWithSnippet } from "@/app/components/render"; +import { DialogContainer } from "@unkey/ui"; +import { Button, Input } from "@unkey/ui"; +import { useState } from "react"; + +export function DialogContainerExample() { + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [inputResult, setInputResult] = useState(""); + + const handleSubmit = () => { + setInputResult(inputValue); + setIsOpen(false); + }; + + return ( + +
+
+ + + setIsOpen(!isOpen)} + subTitle="This is an example of a subTitle. Normally used to describe the dialog" + title="Example Dialog Title" + footer={ +
+ +
+ This is an example of a footer with a button for actions needed to be done +
+
+ } + > +
+

Dialog Content

+ setInputValue(e.target.value)} + /> +
+
+

+ Input Result: {inputResult} +

+
+
+
+ ); +} diff --git a/apps/engineering/content/design/components/dialog-container.mdx b/apps/engineering/content/design/components/dialog-container.mdx new file mode 100644 index 0000000000..5b353ff998 --- /dev/null +++ b/apps/engineering/content/design/components/dialog-container.mdx @@ -0,0 +1,36 @@ +--- +title: Dialog Container +--- +import { DialogContainerExample } from "./dialog-container.example" + +## Dialog Container + +The Dialog Container is a flexible modal component that provides a consistent way to display content in a modal dialog. It's built on top of Radix UI's Dialog primitive with additional styling and functionality. + +### Features + +- Accessible modal implementation +- Customizable overlay and content styling +- Close button with warning support +- Keyboard navigation support +- Customizable animations +- Responsive design + +### Usage + + + +### Props + +| Prop | Type | Default | Description | +|-------------------|-------------------------|-----------|--------------------------------------------------| +| isOpen | boolean | - | Controls the open state of the dialog | +| onOpenChange | (value: boolean) => void | - | Callback when the open state changes | +| title | string | - | The title of the dialog | +| subTitle | string | - | Optional subtitle for the dialog | +| footer | ReactNode | - | Optional footer content | +| className | string | - | Additional classes for the dialog container | +| contentClassName | string | - | Additional classes for the dialog content | +| preventAutoFocus | boolean | false | Whether to prevent auto-focus on open | +| children | ReactNode | - | The content to display in the dialog | + diff --git a/internal/ui/package.json b/internal/ui/package.json index b4a28ad273..f261e9e515 100644 --- a/internal/ui/package.json +++ b/internal/ui/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.0.7", diff --git a/internal/ui/src/components/dialog/dialog-container.tsx b/internal/ui/src/components/dialog/dialog-container.tsx new file mode 100644 index 0000000000..54aba4b573 --- /dev/null +++ b/internal/ui/src/components/dialog/dialog-container.tsx @@ -0,0 +1,52 @@ +"use client"; +import type { PropsWithChildren, ReactNode } from "react"; +// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. +import React from "react"; +import { cn } from "../../lib/utils"; +import { Dialog, DialogContent } from "./dialog"; +import { DefaultDialogContentArea, DefaultDialogFooter, DefaultDialogHeader } from "./dialog-parts"; + +type DialogContainerProps = PropsWithChildren<{ + className?: string; + isOpen: boolean; + onOpenChange: (value: boolean) => void; + title: string; + footer?: ReactNode; + contentClassName?: string; + preventAutoFocus?: boolean; + subTitle?: string; +}>; + +export const DialogContainer = ({ + className, + isOpen, + subTitle, + onOpenChange, + title, + children, + footer, + contentClassName, + preventAutoFocus = true, +}: DialogContainerProps) => { + return ( + + { + if (preventAutoFocus) { + e.preventDefault(); + } + }} + > + + {children} + {footer && {footer}} + + + ); +}; + +export { DefaultDialogHeader, DefaultDialogContentArea, DefaultDialogFooter }; diff --git a/internal/ui/src/components/dialog/dialog-parts.tsx b/internal/ui/src/components/dialog/dialog-parts.tsx new file mode 100644 index 0000000000..59b18cd975 --- /dev/null +++ b/internal/ui/src/components/dialog/dialog-parts.tsx @@ -0,0 +1,59 @@ +"use client"; + +// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. +import * as React from "react"; +import type { PropsWithChildren } from "react"; +import { cn } from "../../lib/utils"; +import { + DialogFooter as ShadcnDialogFooter, + DialogHeader as ShadcnDialogHeader, + DialogTitle as ShadcnDialogTitle, +} from "./dialog"; + +type DefaultDialogHeaderProps = { + title: string; + subTitle?: string; + className?: string; +}; + +export const DefaultDialogHeader = ({ title, subTitle, className }: DefaultDialogHeaderProps) => { + return ( + + + {title} + {subTitle && ( // Conditionally render subtitle span only if it exists + {subTitle} + )} + + + ); +}; + +type DefaultDialogContentAreaProps = PropsWithChildren<{ + className?: string; +}>; + +export const DefaultDialogContentArea = ({ + children, + className, +}: DefaultDialogContentAreaProps) => { + return ( +
+ {children} +
+ ); +}; + +type DefaultDialogFooterProps = PropsWithChildren<{ + className?: string; +}>; + +export const DefaultDialogFooter = ({ children, className }: DefaultDialogFooterProps) => { + return ( + + {children} + + ); +}; diff --git a/internal/ui/src/components/dialog/dialog.tsx b/internal/ui/src/components/dialog/dialog.tsx new file mode 100644 index 0000000000..7a26baad92 --- /dev/null +++ b/internal/ui/src/components/dialog/dialog.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import React from "react"; +import { cn } from "../../lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseWarning?: boolean; + onAttemptClose?: () => void; + } +>(({ className, showCloseWarning = false, onAttemptClose, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseWarning?: boolean; + onAttemptClose?: () => void; + xButtonRef?: React.RefObject; + } +>( + ( + { className, children, showCloseWarning = false, onAttemptClose, xButtonRef, ...props }, + ref, + ) => { + const handleCloseAttempt = React.useCallback(() => { + // This handler is now only called when showCloseWarning is true + if (showCloseWarning) { + onAttemptClose?.(); + } + }, [showCloseWarning, onAttemptClose]); + + // Common class names for both button types + const buttonClassNames = + "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent text-muted-foreground z-[51]"; + + return ( + + + { + if (showCloseWarning) { + e.preventDefault(); + handleCloseAttempt(); + } + }} + onPointerDownOutside={(e) => { + // Prevent closing only if warning is active and click is outside content + if (showCloseWarning) { + // Basic check: If the target is the overlay, it's handled there. + // More robust checks might be needed depending on content complexity. + const contentElement = (e.target as HTMLElement)?.closest('[role="dialog"]'); + if (!contentElement || contentElement !== e.currentTarget) { + e.preventDefault(); + handleCloseAttempt(); + } + } + }} + {...props} + > + {children} + + {/* Conditionally render the close button */} + {showCloseWarning ? ( + + ) : ( + // Use DialogPrimitive.Close for standard behavior + + + + )} + + + ); + }, +); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/internal/ui/src/index.ts b/internal/ui/src/index.ts index 06bb240e71..6aeb4d86b5 100644 --- a/internal/ui/src/index.ts +++ b/internal/ui/src/index.ts @@ -9,4 +9,6 @@ export * from "./components/select"; export * from "./components/settings-card"; export * from "./components/textarea"; export * from "./components/timestamp-info"; +export * from "./components/settings-card"; +export * from "./components/dialog/dialog-container"; export * from "./components/tooltip"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90aba1261c..df969aef0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -916,6 +916,9 @@ importers: '@radix-ui/colors': specifier: ^3.0.0 version: 3.0.0 + '@radix-ui/react-dialog': + specifier: ^1.0.5 + version: 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-select': specifier: ^2.0.0 version: 2.2.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) @@ -8213,6 +8216,26 @@ packages: '@types/react-dom': 18.3.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-presence@1.1.4(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.11)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: true /@radix-ui/react-primitive@1.0.0(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==} @@ -10682,7 +10705,7 @@ packages: /@types/node-fetch@2.6.12: resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} dependencies: - '@types/node': 22.14.0 + '@types/node': 20.14.9 form-data: 4.0.2 /@types/node@12.20.55: @@ -22556,7 +22579,7 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 dependencies: - '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: