mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
feat(frontend): add dialog component (#10254)
## Changes 🏗️ ### Overview Introduces a new responsive `<Dialog />` component that automatically adapts to screen size, providing optimal UX across devices. <img width="800" alt="Screenshot 2025-06-27 at 16 00 01" src="https://github.com/user-attachments/assets/d0c53b30-488f-4102-8100-c9318168d65b" /> <img width="300" alt="Screenshot 2025-06-27 at 16 00 12" src="https://github.com/user-attachments/assets/f2105708-97d9-4a94-8e26-3c2d582ea8cd" /> ### Key Features #### 📱 **Responsive Behavior** - **Desktop**: Modal dialog with overlay - **Mobile**: Bottom drawer [Vaul](https://vaul.emilkowal.ski/) with **swipe-to-dismiss** functionality #### 🎯 **Multiple Interaction Methods** - `ESC` key to close (both desktop & mobile) - Click outside to dismiss - Swipe down to dismiss (mobile drawer) - Close button (X) #### ❓ Why I did not use `shadcn/dialog` in this case as a base While we already use the raw `shadcn/dialog` on the platform, it's designed as a desktop-only solution and is not really responsive-friendly. It lacks 📱 mobile-optimisation patterns like _bottom drawers_, _swipe-to-dismiss gestures_ ( the new implementation has it via [Vaul](https://vaul.emilkowal.ski/) ), and automatic breakpoint adaptation according to screen size. #### 🧩 **Compound Component Pattern** ```tsx <Dialog title="Example"> <Dialog.Trigger> <Button>Open Dialog</Button> </Dialog.Trigger> <Dialog.Content> Content goes here </Dialog.Content> </Dialog> ``` #### ⚙️ **Flexible Control** - **Uncontrolled**: Self-managed state via triggers - **Controlled**: External state management - **Force open**: rare but might be needed - **Custom styling**: if needed ## Checklist 📋 - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] **Desktop Modal**: Opens/closes via trigger, ESC key, click outside, close button - [x] **Mobile Drawer**: Automatically switches at `lg` breakpoint, swipe-to-dismiss works - [x] **Controlled Mode**: External state management functions correctly - [x] **Force Open**: Dialog stays open for preview purposes - [x] **Custom Styling**: CSS-in-JS overrides work as expected - [x] **Footer Component**: Action buttons render and function properly - [x] **No Title Mode**: Dialog works without title prop - [x] **Accessibility**: Tab navigation, screen reader announcements, ARIA compliance - [x] **Responsive Breakpoints**: Component switches modes at correct screen sizes - [x] **Storybook**: All stories render and function correctly --------- Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@ const config: StorybookConfig = {
|
||||
"../src/components/overview.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/tokens/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/atoms/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
"../src/components/agptui/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
18
autogpt_platform/frontend/pnpm-lock.yaml
generated
18
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -200,6 +200,9 @@ importers:
|
||||
uuid:
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0
|
||||
vaul:
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
zod:
|
||||
specifier: 3.25.56
|
||||
version: 3.25.56
|
||||
@@ -6866,6 +6869,12 @@ packages:
|
||||
resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vfile-message@4.0.2:
|
||||
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
|
||||
|
||||
@@ -14701,6 +14710,15 @@ snapshots:
|
||||
|
||||
validator@13.15.15: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.14(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vfile-message@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: "Molecules/Dialog",
|
||||
component: Dialog,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A responsive dialog component that automatically switches between modal dialog (desktop) and drawer (mobile). Built on top of Radix UI Dialog and Vaul drawer with custom styling. Supports compound components: Dialog.Trigger, Dialog.Content, and Dialog.Footer.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Dialog title - can be string or React node",
|
||||
},
|
||||
forceOpen: {
|
||||
control: "boolean",
|
||||
description: "Force the dialog to stay open (useful for previewing)",
|
||||
},
|
||||
styling: {
|
||||
control: "object",
|
||||
description: "Custom CSS styles object",
|
||||
},
|
||||
onClose: {
|
||||
action: "closed",
|
||||
description: "Callback fired when dialog closes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
title: "Dialog Title",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: renderBasicDialog,
|
||||
};
|
||||
|
||||
export const WithoutTitle: Story = {
|
||||
render: renderDialogWithoutTitle,
|
||||
};
|
||||
|
||||
export const ForceOpen: Story = {
|
||||
args: {
|
||||
forceOpen: true,
|
||||
title: "Preview Dialog",
|
||||
},
|
||||
render: renderForceOpenDialog,
|
||||
};
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: renderDialogWithFooter,
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: renderControlledDialog,
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: renderCustomStyledDialog,
|
||||
};
|
||||
|
||||
function renderBasicDialog() {
|
||||
return (
|
||||
<Dialog title="Basic Dialog">
|
||||
<Dialog.Trigger>
|
||||
<Button variant="primary">Open Dialog</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<p>This is a basic dialog with some content.</p>
|
||||
<p>
|
||||
It automatically adapts to screen size - modal on desktop, drawer on
|
||||
mobile.
|
||||
</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDialogWithoutTitle() {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="primary">Open Dialog (no title)</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<p className="flex min-h-[100px] flex-row items-center justify-center">
|
||||
This dialog doesn't use the title prop, allowing for no header or
|
||||
a custom header.
|
||||
</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderForceOpenDialog(args: any) {
|
||||
return (
|
||||
<Dialog {...args}>
|
||||
<Dialog.Content>
|
||||
<p>This dialog is forced open for preview purposes.</p>
|
||||
<p>
|
||||
In real usage, you'd typically use a trigger or controlled state.
|
||||
</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDialogWithFooter() {
|
||||
return (
|
||||
<Dialog title="Dialog with Footer">
|
||||
<Dialog.Trigger>
|
||||
<Button variant="primary">Open Dialog with Footer</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<p>This dialog includes a footer with action buttons.</p>
|
||||
<p>Use the footer for primary and secondary actions.</p>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" size="small">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="small">
|
||||
Confirm
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderControlledDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="primary" onClick={handleToggle}>
|
||||
{isOpen ? "Close" : "Open"} Controlled Dialog
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
title="Controlled Dialog"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
onClose={() => console.log("Dialog closed")}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>This dialog is controlled by external state.</p>
|
||||
<p>
|
||||
Open state:{" "}
|
||||
<span className="font-bold">{isOpen ? "Open" : "Closed"}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleToggle} className="mt-8" size="small">
|
||||
Close this modal
|
||||
</Button>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomStyledDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
title="Custom Styled Dialog"
|
||||
styling={{
|
||||
maxWidth: "800px",
|
||||
backgroundColor: "rgb(248 250 252)",
|
||||
border: "2px solid rgb(59 130 246)",
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="primary">Open Custom Styled Dialog</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<p>This dialog has custom styling applied.</p>
|
||||
<p>You can customize dimensions, colors, and other CSS properties.</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import * as RXDialog from "@radix-ui/react-dialog";
|
||||
import { CSSProperties, PropsWithChildren } from "react";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
import { BaseContent } from "./components/BaseContent";
|
||||
import { BaseFooter } from "./components/BaseFooter";
|
||||
import { BaseTrigger } from "./components/BaseTrigger";
|
||||
import { DialogCtx, useDialogCtx } from "./useDialogCtx";
|
||||
import { useDialogInternal } from "./useDialogInternal";
|
||||
|
||||
interface Props extends PropsWithChildren {
|
||||
title?: React.ReactNode;
|
||||
styling?: CSSProperties;
|
||||
|
||||
forceOpen?: boolean;
|
||||
onClose?: (() => void) | undefined;
|
||||
controlled?: {
|
||||
isOpen: boolean;
|
||||
set: (open: boolean) => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
Dialog.Trigger = BaseTrigger;
|
||||
Dialog.Content = BaseContent;
|
||||
Dialog.Footer = BaseFooter;
|
||||
|
||||
function Dialog({
|
||||
children,
|
||||
title,
|
||||
styling,
|
||||
|
||||
forceOpen = false,
|
||||
onClose,
|
||||
controlled,
|
||||
}: Props) {
|
||||
const config = useDialogInternal({ controlled });
|
||||
const isOpen = forceOpen || config.isOpen;
|
||||
|
||||
return (
|
||||
<DialogCtx.Provider
|
||||
value={{
|
||||
title: title || "",
|
||||
styling,
|
||||
|
||||
isOpen,
|
||||
isForceOpen: forceOpen,
|
||||
isLargeScreen: config.isLgScreenUp,
|
||||
handleOpen: config.handleOpen,
|
||||
handleClose: async () => {
|
||||
await config.handleClose();
|
||||
onClose?.();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{config.isLgScreenUp ? (
|
||||
<RXDialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
config.handleClose();
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RXDialog.Root>
|
||||
) : (
|
||||
<Drawer.Root
|
||||
shouldScaleBackground
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
config.handleClose();
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Drawer.Root>
|
||||
)}
|
||||
</DialogCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Dialog, useDialogCtx };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
import { useDialogCtx } from "../useDialogCtx";
|
||||
import { DialogWrap } from "./DialogWrap";
|
||||
import { DrawerWrap } from "./DrawerWrap";
|
||||
|
||||
type Props = PropsWithChildren;
|
||||
|
||||
export function BaseContent({ children }: Props) {
|
||||
const ctx = useDialogCtx();
|
||||
|
||||
return ctx.isLargeScreen ? (
|
||||
<DialogWrap {...ctx}>{children}</DialogWrap>
|
||||
) : (
|
||||
<DrawerWrap {...ctx}>{children}</DrawerWrap>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useDialogCtx } from "../useDialogCtx";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BaseFooter({
|
||||
children,
|
||||
testId = "modal-footer",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const ctx = useDialogCtx();
|
||||
|
||||
return ctx.isLargeScreen ? (
|
||||
<div
|
||||
className={`flex justify-end gap-4 pt-6 ${className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex w-full items-end justify-between gap-4 pt-6 ${className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { useDialogCtx } from "../useDialogCtx";
|
||||
|
||||
export function BaseTrigger({ children }: PropsWithChildren) {
|
||||
const ctx = useDialogCtx();
|
||||
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
onClick: ctx.handleOpen,
|
||||
className: "cursor-pointer",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import * as RXDialog from "@radix-ui/react-dialog";
|
||||
import { CSSProperties, PropsWithChildren } from "react";
|
||||
import { DialogCtx } from "../useDialogCtx";
|
||||
import { modalStyles } from "./styles";
|
||||
|
||||
type BaseProps = DialogCtx & PropsWithChildren;
|
||||
|
||||
interface Props extends BaseProps {
|
||||
title: React.ReactNode;
|
||||
styling: CSSProperties | undefined;
|
||||
withGradient?: boolean;
|
||||
}
|
||||
|
||||
export function DialogWrap({
|
||||
children,
|
||||
title,
|
||||
styling = {},
|
||||
isForceOpen,
|
||||
handleClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<RXDialog.Portal>
|
||||
<RXDialog.Overlay className={modalStyles.overlay} />
|
||||
<RXDialog.Content
|
||||
onInteractOutside={handleClose}
|
||||
onEscapeKeyDown={handleClose}
|
||||
aria-describedby={undefined}
|
||||
className={cn(modalStyles.content)}
|
||||
style={{
|
||||
...styling,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between ${
|
||||
title ? "pb-6" : "pb-0"
|
||||
}`}
|
||||
>
|
||||
{title ? (
|
||||
<RXDialog.Title className={modalStyles.title}>
|
||||
{title}
|
||||
</RXDialog.Title>
|
||||
) : (
|
||||
<span className="sr-only">
|
||||
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
|
||||
<RXDialog.Title>{title}</RXDialog.Title>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isForceOpen && !handleClose ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-stone-900`}
|
||||
>
|
||||
<X className={modalStyles.icon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto">{children}</div>
|
||||
</RXDialog.Content>
|
||||
</RXDialog.Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Drawer } from "vaul";
|
||||
import { DialogCtx } from "../useDialogCtx";
|
||||
import { drawerStyles, modalStyles } from "./styles";
|
||||
|
||||
type BaseProps = DialogCtx & PropsWithChildren;
|
||||
|
||||
interface Props extends BaseProps {
|
||||
testId?: string;
|
||||
title: React.ReactNode;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
export function DrawerWrap({
|
||||
children,
|
||||
title,
|
||||
testId,
|
||||
handleClose,
|
||||
isForceOpen,
|
||||
}: Props) {
|
||||
const closeBtn = (
|
||||
<Button variant="link" aria-label="Close" onClick={handleClose}>
|
||||
<X width="1.5rem" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className={drawerStyles.overlay} />
|
||||
<Drawer.Content
|
||||
aria-describedby={undefined}
|
||||
className={drawerStyles.content}
|
||||
data-testid={testId}
|
||||
onInteractOutside={handleClose}
|
||||
>
|
||||
<div
|
||||
className={`flex w-full items-center justify-between ${
|
||||
title ? "pb-6" : "pb-0"
|
||||
}`}
|
||||
>
|
||||
{title ? (
|
||||
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
|
||||
) : null}
|
||||
|
||||
{!isForceOpen ? (
|
||||
title ? (
|
||||
closeBtn
|
||||
) : (
|
||||
<div
|
||||
className={`${modalStyles.iconWrap} transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700`}
|
||||
>
|
||||
{closeBtn}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-auto">{children}</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Common styles as Tailwind class strings
|
||||
const commonStyles = {
|
||||
title: "font-poppins text-md md:text-lg leading-none",
|
||||
overlay:
|
||||
"fixed inset-0 z-50 bg-stone-500/20 dark:bg-black/50 backdrop-blur-md animate-fade-in",
|
||||
content:
|
||||
"bg-stone-100 dark:bg-stone-800 p-6 fixed rounded-xl flex flex-col z-50 w-full overflow-hidden",
|
||||
};
|
||||
|
||||
// Modal specific styles
|
||||
export const modalStyles = {
|
||||
...commonStyles,
|
||||
content: `${commonStyles.content} p-6 border border-stone-200 dark:border-stone-700 overflow-y-auto min-w-[40vw] max-w-[60vw] max-h-[95vh] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-fadein`,
|
||||
iconWrap:
|
||||
"absolute top-2 right-3 bg-transparent p-2 rounded-full transition-colors duration-300 ease-in-out outline-none border-none",
|
||||
icon: "w-4 h-4 text-stone-800 dark:text-stone-300",
|
||||
};
|
||||
|
||||
// Drawer specific styles
|
||||
export const drawerStyles = {
|
||||
...commonStyles,
|
||||
content: `${commonStyles.content} max-h-[90vh] w-full bottom-0 rounded-br-none rounded-bl-none`,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CSSProperties, createContext, useContext } from "react";
|
||||
|
||||
export function useDialogCtx() {
|
||||
const modalContext = useContext(DialogCtx);
|
||||
|
||||
return modalContext;
|
||||
}
|
||||
|
||||
export interface DialogCtx {
|
||||
title: React.ReactNode;
|
||||
handleOpen: () => void;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
isForceOpen: boolean;
|
||||
isLargeScreen: boolean;
|
||||
styling: CSSProperties | undefined;
|
||||
}
|
||||
|
||||
export const DialogCtx = createContext<DialogCtx>({
|
||||
title: "",
|
||||
isOpen: false,
|
||||
isForceOpen: false,
|
||||
isLargeScreen: true,
|
||||
handleOpen: () => undefined,
|
||||
handleClose: () => undefined,
|
||||
styling: {},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Args = {
|
||||
controlled:
|
||||
| {
|
||||
isOpen: boolean;
|
||||
set: (open: boolean) => Promise<void> | void;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export function useDialogInternal({ controlled }: Args) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const [isLgScreenUp, setIsLgScreenUp] = useState(isLargeScreen(breakpoint));
|
||||
|
||||
useEffect(() => {
|
||||
setIsLgScreenUp(isLargeScreen(breakpoint));
|
||||
}, [breakpoint]);
|
||||
|
||||
// if first opened as modal, or drawer - we need to keep it this way
|
||||
// because, given the current implementation, we can't switch between modal and drawer without a full remount
|
||||
|
||||
async function handleOpen() {
|
||||
setIsLgScreenUp(isLargeScreen(breakpoint));
|
||||
|
||||
if (controlled) {
|
||||
await controlled.set(true);
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
if (controlled) {
|
||||
await controlled.set(false);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: controlled ? controlled.isOpen : isOpen,
|
||||
handleOpen,
|
||||
handleClose,
|
||||
isLgScreenUp,
|
||||
};
|
||||
}
|
||||
50
autogpt_platform/frontend/src/lib/hooks/useBreakpoint.ts
Normal file
50
autogpt_platform/frontend/src/lib/hooks/useBreakpoint.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type Breakpoint = "base" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
|
||||
// Explicitly maps to tailwind breakpoints
|
||||
const breakpoints: Record<Breakpoint, number> = {
|
||||
base: 0,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
|
||||
|
||||
useEffect(() => {
|
||||
const getBreakpoint = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < breakpoints.sm) return "base";
|
||||
if (width < breakpoints.md) return "sm";
|
||||
if (width < breakpoints.lg) return "md";
|
||||
if (width < breakpoints.xl) return "lg";
|
||||
if (width < breakpoints["2xl"]) return "xl";
|
||||
return "2xl";
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
const current = getBreakpoint();
|
||||
setBreakpoint(current);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize(); // initial call
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
export function isLargeScreen(bp: Breakpoint) {
|
||||
if (bp === "sm") return false;
|
||||
if (bp === "md") return false;
|
||||
if (bp === "lg") return true;
|
||||
if (bp === "xl") return true;
|
||||
if (bp === "2xl") return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user